Skip to main content

issundb_core/graph/
mod.rs

1use std::{
2    any::{Any, TypeId as StdTypeId},
3    collections::HashMap,
4    path::Path,
5    sync::Arc,
6};
7
8use parking_lot::ReentrantMutex;
9use serde::Serialize;
10use tracing::instrument;
11use zerocopy::{FromBytes, IntoBytes};
12
13use ahash::{AHashMap, AHashSet};
14
15use crate::matrices::MatrixSet;
16use crate::{
17    csr::{CsrCache, CsrSnapshot},
18    error::Error,
19    schema::{
20        AdjEntry, DirectedNeighborEntry, EdgeId, EdgeRecord, LabelId, Language, NeighborEntry,
21        NodeId, NodeRecord, PropKeyId, PropValue, TypeId, WeightedPath,
22    },
23    storage::{
24        fts,
25        ids::{
26            adjust_label_count, adjust_type_count, alloc_edge_id, alloc_node_id, get_label,
27            get_or_create_label, get_or_create_prop_key, get_or_create_type, get_prop_key,
28            get_prop_key_name, get_type,
29        },
30        lmdb::Storage,
31        props,
32    },
33};
34
35pub mod algo;
36pub mod edge;
37pub mod fts_mod;
38pub mod graphblas;
39pub mod index;
40pub mod node;
41pub mod stats;
42pub mod txn;
43pub mod vector;
44
45/// The direction of edges to count for degree centrality.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
47pub enum DegreeDirection {
48    /// Count incoming edges only.
49    In,
50    /// Count outgoing edges only.
51    Out,
52    /// Count both incoming and outgoing edges.
53    Both,
54}
55
56/// Pattern description for [`Graph::count_triangle_cycles`]: the directed
57/// cycle `(a)-[t1]->(b)-[t2]->(c)-[t3]->(a)` with an optional relationship
58/// type per hop and an optional label per node variable. `None` means
59/// unconstrained.
60#[derive(Debug, Clone, Default)]
61pub struct TriangleCountSpec<'a> {
62    /// Relationship types for the hops `a -> b`, `b -> c`, and `c -> a`.
63    pub rel_types: [Option<&'a str>; 3],
64    /// Labels required on `a`, `b`, and `c`.
65    pub labels: [Option<&'a str>; 3],
66}
67
68/// Pattern description for [`Graph::count_linear_paths`]: an open directed
69/// path of one or two hops, `(v0)-[t1]->(v1)` or
70/// `(v0)-[t1]->(v1)-[t2]->(v2)`, with an optional relationship type per hop
71/// and an optional label per node variable. `None` means unconstrained.
72///
73/// `rel_types.len()` is the hop count (1 or 2); `labels.len()` is the node
74/// count (hop count plus one). The two-hop count follows Cypher MATCH
75/// relationship-uniqueness semantics: the two relationships must be distinct,
76/// which only constrains self-loop assignments where one edge could fill both
77/// hops.
78#[derive(Debug, Clone, Default)]
79pub struct PathCountSpec<'a> {
80    /// Relationship type per hop, in path order. Length 1 or 2.
81    pub rel_types: Vec<Option<&'a str>>,
82    /// Label per node variable, in path order. Length is `rel_types.len() + 1`.
83    pub labels: Vec<Option<&'a str>>,
84    /// Optional explicit allow-set of node ids per variable, in path order. A
85    /// `Some(ids)` entry restricts that variable to `ids` (intersected with its
86    /// label, if any); `None` leaves it unconstrained beyond the label. The
87    /// caller resolves these sets by pushing per-vertex property predicates down
88    /// into index lookups, so a filtered path count stays a kernel call instead
89    /// of materializing rows. An empty vector (the default) means no variable is
90    /// constrained, identical to the unfiltered path count.
91    pub vertex_allow: Vec<Option<Vec<NodeId>>>,
92}
93
94/// Pattern description for [`Graph::grouped_edge_counts`]: count typed edges
95/// grouped by one endpoint. With `group_is_dst`, edges are grouped by their
96/// destination and the source is the counted endpoint (in-degree per
97/// destination); otherwise edges are grouped by their source and the
98/// destination is counted (out-degree per source). `group_label` and
99/// `counted_label` optionally constrain each endpoint (`None` is
100/// unconstrained). `counted_nonnull_prop` counts an edge only when the counted
101/// endpoint's property is non-null (the semantics of `count(v.prop)` over the
102/// expansion); `None` counts every qualifying edge (the semantics of
103/// `count(*)` or `count(v)`, where a bound node variable is never null).
104#[derive(Debug, Clone, Default)]
105pub struct GroupedDegreeSpec<'a> {
106    /// Relationship type to count, or `None` for any type.
107    pub rel_type: Option<&'a str>,
108    /// Group by the edge destination (count incoming) when true; by the edge
109    /// source (count outgoing) when false.
110    pub group_is_dst: bool,
111    /// Label required on the group endpoint.
112    pub group_label: Option<&'a str>,
113    /// Label required on the counted endpoint.
114    pub counted_label: Option<&'a str>,
115    /// Property that must be non-null on the counted endpoint for an edge to
116    /// count; `None` counts every qualifying edge.
117    pub counted_nonnull_prop: Option<&'a str>,
118}
119
120/// Builds a 12-byte composite key `(prefix u32 BE, id u64 BE)` for secondary index lookups.
121pub(super) fn composite_key(prefix: u32, id: u64) -> [u8; 12] {
122    let mut key = [0u8; 12];
123    key[..4].copy_from_slice(&prefix.to_be_bytes());
124    key[4..].copy_from_slice(&id.to_be_bytes());
125    key
126}
127
128/// Type tag for a null value in the sortable property encoding.
129pub(super) const ENCODED_NULL: u8 = 0x00;
130
131/// Sign bit mask used to make IEEE-754 `f64` bit patterns and two's-complement
132/// `i64` values sort in ascending numeric order as big-endian bytes.
133const SORT_SIGN_BIT: u64 = 0x8000_0000_0000_0000;
134
135/// Maximum string length (in bytes) that can be auto-indexed. The property
136/// index key is `(label_id, prop_key_id, encoded_val, node_id)`, so it carries
137/// 16 bytes of fixed fields plus the 2-byte string-encoding frame (`0x04` tag
138/// and `0x00` terminator) around the value. LMDB's default maximum key size is
139/// 511 bytes; a string longer than this would overflow that limit and cannot be
140/// indexed, so `encode_property_value` declines it and the value is left
141/// unindexed (equality lookups fall back to a scan, and long text belongs in a
142/// full-text index anyway). The bound is conservative to leave headroom.
143pub(super) const MAX_INDEXED_STRING_LEN: usize = 480;
144
145/// Encodes a JSON property value into a sortable byte representation for the index.
146///
147/// Numbers use a fixed 17-byte encoding: a `0x03` tag, then 8 bytes of the
148/// order-preserving `f64` bit pattern (the primary numeric sort key), then 8
149/// bytes of an integer disambiguator. The disambiguator makes the encoding
150/// lossless for `i64` values: two integers that round to the same `f64` (any
151/// pair beyond 2^53) still produce distinct keys, while an integer and a float
152/// of the same real value (e.g. `30` and `30.0`) produce identical keys so they
153/// continue to compare equal. Keeping every numeric encoding the same length is
154/// required because property lookups match by key prefix; a variable-length
155/// encoding where one value is a prefix of another would yield false matches.
156pub(super) fn encode_property_value(val: &serde_json::Value) -> Option<Vec<u8>> {
157    match val {
158        serde_json::Value::Null => Some(vec![ENCODED_NULL]),
159        serde_json::Value::Bool(false) => Some(vec![0x01]),
160        serde_json::Value::Bool(true) => Some(vec![0x02]),
161        serde_json::Value::Number(num) => {
162            let float_val = num.as_f64()?;
163            let bits = float_val.to_bits();
164            let masked = if (bits & SORT_SIGN_BIT) != 0 {
165                !bits
166            } else {
167                bits ^ SORT_SIGN_BIT
168            };
169            // Integer disambiguator: for any number whose exact real value is an
170            // integer in `i64` range, store that integer in sign-flipped
171            // big-endian order so distinct large integers never collide. All
172            // other numbers (non-integers, out-of-range) get a fixed sentinel;
173            // they already have a unique `f64` bit pattern in the primary key,
174            // so the sentinel value cannot affect ordering or equality.
175            let int_disambig: u64 = if let Some(i) = num.as_i64() {
176                (i as u64) ^ SORT_SIGN_BIT
177            } else if float_val.fract() == 0.0
178                && float_val >= i64::MIN as f64
179                && float_val <= i64::MAX as f64
180            {
181                ((float_val as i64) as u64) ^ SORT_SIGN_BIT
182            } else {
183                0
184            };
185            let mut buf = Vec::with_capacity(17);
186            buf.push(0x03);
187            buf.extend_from_slice(&masked.to_be_bytes());
188            buf.extend_from_slice(&int_disambig.to_be_bytes());
189            Some(buf)
190        }
191        serde_json::Value::String(s) => {
192            // A string too long to fit an LMDB key cannot be indexed; decline it
193            // so the property is left unindexed rather than crashing the write.
194            if s.len() > MAX_INDEXED_STRING_LEN {
195                return None;
196            }
197            let mut buf = Vec::with_capacity(1 + s.len() + 1);
198            buf.push(0x04);
199            buf.extend_from_slice(s.as_bytes());
200            buf.push(0x00);
201            Some(buf)
202        }
203        _ => None, // Skip arrays and objects
204    }
205}
206
207/// Decodes a sortable byte representation back into a JSON property value.
208#[allow(dead_code)]
209pub(super) fn decode_property_value(bytes: &[u8]) -> Option<serde_json::Value> {
210    if bytes.is_empty() {
211        return None;
212    }
213    match bytes[0] {
214        0x00 => Some(serde_json::Value::Null),
215        0x01 => Some(serde_json::Value::Bool(false)),
216        0x02 => Some(serde_json::Value::Bool(true)),
217        0x03 => {
218            // Numbers are `tag + 8-byte f64 sort key + 8-byte int disambiguator`.
219            if bytes.len() < 17 {
220                return None;
221            }
222            // Prefer the lossless integer disambiguator when it round-trips,
223            // so large integers decode exactly rather than through `f64`.
224            let mut int_arr = [0u8; 8];
225            int_arr.copy_from_slice(&bytes[9..17]);
226            let int_val = (u64::from_be_bytes(int_arr) ^ SORT_SIGN_BIT) as i64;
227
228            let mut arr = [0u8; 8];
229            arr.copy_from_slice(&bytes[1..9]);
230            let masked = u64::from_be_bytes(arr);
231            let bits = if (masked & SORT_SIGN_BIT) == 0 {
232                !masked
233            } else {
234                masked ^ SORT_SIGN_BIT
235            };
236            let float_val = f64::from_bits(bits);
237
238            // If the disambiguator's integer equals the float key, the value was
239            // an integer (or integer-valued float): return it losslessly as an
240            // integer. Non-integers store a sentinel whose sign-flipped form is
241            // `i64::MIN`, which never matches a non-integer float key.
242            if (int_val as f64) == float_val {
243                Some(serde_json::Value::Number(int_val.into()))
244            } else {
245                serde_json::Number::from_f64(float_val).map(serde_json::Value::Number)
246            }
247        }
248        0x04 => {
249            let str_bytes = if bytes.ends_with(&[0x00]) {
250                &bytes[1..bytes.len() - 1]
251            } else {
252                &bytes[1..]
253            };
254            String::from_utf8(str_bytes.to_vec())
255                .ok()
256                .map(serde_json::Value::String)
257        }
258        _ => None,
259    }
260}
261
262/// Builds a composite key `(label_id, prop_key_id, encoded_val, node_id)` for node property index.
263pub(super) fn node_prop_index_key(
264    label_id: LabelId,
265    prop_key_id: PropKeyId,
266    encoded_val: &[u8],
267    node_id: NodeId,
268) -> Vec<u8> {
269    let mut key = Vec::with_capacity(4 + 4 + encoded_val.len() + 8);
270    key.extend_from_slice(&label_id.to_be_bytes());
271    key.extend_from_slice(&prop_key_id.to_be_bytes());
272    key.extend_from_slice(encoded_val);
273    key.extend_from_slice(&node_id.to_be_bytes());
274    key
275}
276
277/// Builds a composite key `(type_id, prop_key_id, encoded_val, edge_id)` for edge property index.
278pub(super) fn edge_prop_index_key(
279    type_id: TypeId,
280    prop_key_id: PropKeyId,
281    encoded_val: &[u8],
282    edge_id: EdgeId,
283) -> Vec<u8> {
284    let mut key = Vec::with_capacity(4 + 4 + encoded_val.len() + 8);
285    key.extend_from_slice(&type_id.to_be_bytes());
286    key.extend_from_slice(&prop_key_id.to_be_bytes());
287    key.extend_from_slice(encoded_val);
288    key.extend_from_slice(&edge_id.to_be_bytes());
289    key
290}
291
292/// Builds a composite key `(label_id, prop_key_id, term)` for FTS postings.
293pub(super) fn fts_postings_key(label_id: LabelId, prop_key_id: PropKeyId, term: &str) -> Vec<u8> {
294    let mut key = Vec::with_capacity(8 + term.len());
295    key.extend_from_slice(&label_id.to_be_bytes());
296    key.extend_from_slice(&prop_key_id.to_be_bytes());
297    key.extend_from_slice(term.as_bytes());
298    key
299}
300
301/// Builds a 12-byte FTS posting value `(node_id, frequency)`.
302pub(super) fn fts_posting_val(node_id: NodeId, frequency: u32) -> [u8; 12] {
303    let mut val = [0u8; 12];
304    val[0..8].copy_from_slice(&node_id.to_be_bytes());
305    val[8..12].copy_from_slice(&frequency.to_be_bytes());
306    val
307}
308
309/// Parses a 12-byte FTS posting value into `(node_id, frequency)`.
310pub(super) fn parse_fts_posting_val(bytes: &[u8]) -> Result<(NodeId, u32), Error> {
311    if bytes.len() != 12 {
312        return Err(Error::Corrupt("fts posting value must be 12 bytes"));
313    }
314    let node_id = NodeId::from_be_bytes(
315        bytes[0..8]
316            .try_into()
317            .map_err(|_| Error::Corrupt("fts posting: node_id slice wrong size"))?,
318    );
319    let frequency = u32::from_be_bytes(
320        bytes[8..12]
321            .try_into()
322            .map_err(|_| Error::Corrupt("fts posting: frequency slice wrong size"))?,
323    );
324    Ok((node_id, frequency))
325}
326
327/// Builds a 16-byte FTS doc key `(label_id, prop_key_id, node_id)`.
328pub(super) fn fts_doc_key(label_id: LabelId, prop_key_id: PropKeyId, node_id: NodeId) -> [u8; 16] {
329    let mut key = [0u8; 16];
330    key[0..4].copy_from_slice(&label_id.to_be_bytes());
331    key[4..8].copy_from_slice(&prop_key_id.to_be_bytes());
332    key[8..16].copy_from_slice(&node_id.to_be_bytes());
333    key
334}
335
336/// Parses a 4-byte doc length value.
337pub(super) fn parse_fts_doc_val(bytes: &[u8]) -> Result<u32, Error> {
338    if bytes.len() != 4 {
339        return Err(Error::Corrupt("fts doc val must be 4 bytes"));
340    }
341    Ok(u32::from_be_bytes(bytes.try_into().map_err(|_| {
342        Error::Corrupt("fts doc val: slice wrong size")
343    })?))
344}
345
346pub(super) fn fts_stats_n_key(label_id: LabelId, prop_key_id: PropKeyId) -> String {
347    format!("fts_stats:node:l:{label_id}:p:{prop_key_id}:N")
348}
349
350pub(super) fn fts_stats_sum_dl_key(label_id: LabelId, prop_key_id: PropKeyId) -> String {
351    format!("fts_stats:node:l:{label_id}:p:{prop_key_id}:sum_dl")
352}
353
354/// The graph database handle. Cheap to clone: all state is behind `Arc`.
355#[derive(Clone)]
356pub struct Graph {
357    pub(super) storage: Arc<Storage>,
358    pub(super) _write_lock: Arc<ReentrantMutex<()>>,
359    pub(super) csr_cache: Arc<CsrCache>,
360    pub(super) matrices: Arc<parking_lot::RwLock<Option<MatrixSet>>>,
361    pub(super) prop_columns: Arc<crate::columns::ColumnsCache<crate::columns::NodeSource>>,
362    pub(super) edge_columns: Arc<crate::columns::ColumnsCache<crate::columns::EdgeSource>>,
363    /// Per-`(label, type)` edge frequencies backing the optimizer's per-source-label
364    /// expand-ratio estimate, recomputed lazily when committed writes advance past
365    /// the cached generation. See [`crate::graph::stats`].
366    pub(super) edge_fanout: Arc<parking_lot::Mutex<Option<crate::graph::stats::EdgeFanout>>>,
367    pub(super) n_threads: Arc<std::sync::atomic::AtomicI32>,
368    /// Type-erased extension cache. Higher-level crates attach caches (e.g. the
369    /// HNSW vector index) to a Graph without creating a circular dependency,
370    /// through the `get_extension`, `set_extension`, and
371    /// `get_or_init_extension_with` methods. Keys are `std::any::TypeId`; values
372    /// are `Arc<dyn Any + Send + Sync>`.
373    pub(crate) extensions: Arc<parking_lot::Mutex<AHashMap<StdTypeId, Box<dyn Any + Send + Sync>>>>,
374}
375
376/// A read-only transaction on the graph.
377pub struct ReadTxn<'a> {
378    pub(super) graph: &'a Graph,
379    pub(super) rtxn: heed::RoTxn<'a, heed::WithTls>,
380}
381
382/// A read-write transaction on the graph.
383pub struct WriteTxn<'a> {
384    pub(super) graph: &'a Graph,
385    pub(super) wtxn: heed::RwTxn<'a>,
386    pub(super) mutations_count: usize,
387    /// Structural mutations staged during this transaction, flushed to the
388    /// `CsrCache` only on commit so an aborted transaction records nothing.
389    pub(super) delta: crate::csr::GraphDelta,
390}
391
392impl Graph {
393    pub fn open(path: &Path, map_size_gb: usize) -> Result<Self, Error> {
394        let storage = Storage::open(path, map_size_gb)?;
395        // Older versions persisted the CSR snapshot next to the LMDB files but
396        // never read it back; remove the stale artifact if one is present.
397        let _ = std::fs::remove_file(path.join("csr_snapshot.bin"));
398        let initial = CsrSnapshot::build(&storage)?;
399        let storage = Arc::new(storage);
400        let csr_cache = Arc::new(CsrCache::new(initial));
401        let matrices = {
402            let initial_snap = csr_cache.snapshot.load();
403            let m = MatrixSet::materialize(&initial_snap, 0)?;
404            Arc::new(parking_lot::RwLock::new(Some(m)))
405        };
406        Ok(Self {
407            storage,
408            _write_lock: Arc::new(ReentrantMutex::new(())),
409            csr_cache,
410            matrices,
411            prop_columns: Arc::new(crate::columns::ColumnsCache::default()),
412            edge_columns: Arc::new(crate::columns::ColumnsCache::default()),
413            edge_fanout: Arc::new(parking_lot::Mutex::new(None)),
414            n_threads: Arc::new(std::sync::atomic::AtomicI32::new(0)),
415            extensions: Arc::new(parking_lot::Mutex::new(AHashMap::new())),
416        })
417    }
418
419    /// Set the thread count for GraphBLAS matrix computations, overriding the
420    /// `ISSUNDB_NUM_THREADS` environment variable. Set to 0 to restore the default behavior.
421    pub fn set_thread_count(&self, n: i32) -> Result<(), Error> {
422        self.n_threads
423            .store(n, std::sync::atomic::Ordering::Release);
424        issundb_graphblas::set_global_threads(n).map_err(|e| Error::GraphBLAS(e.to_string()))?;
425        Ok(())
426    }
427
428    /// Read one property of a node through the in-memory property columns,
429    /// as the `serde_json::Value` that decoding the stored record would give.
430    /// Returns `None` for a nonexistent node and `Some(Value::Null)` for a
431    /// missing property. Builds or refreshes the columns on first use after a
432    /// write, so the result always reflects committed state.
433    pub fn node_prop_json(
434        &self,
435        id: NodeId,
436        prop: &str,
437    ) -> Result<Option<serde_json::Value>, Error> {
438        self.prop_columns.with_fresh(&self.storage, |cols| {
439            cols.id_to_dense.get(&id).map(|&d| {
440                cols.cols
441                    .get(prop)
442                    .and_then(|c| c.get_json_opt(d as usize))
443                    .unwrap_or(serde_json::Value::Null)
444            })
445        })
446    }
447
448    /// Bulk form of [`Graph::node_prop_json`]: gather `props` for each id in
449    /// `ids` through the in-memory property columns, row-major (`out[i][j]` is
450    /// `props[j]` on `ids[i]`). One columns refresh covers the whole gather,
451    /// and each id resolves to its dense index once. A missing property reads
452    /// as `Value::Null`; a nonexistent node is [`Error::NodeNotFound`].
453    pub fn node_props_json_table(
454        &self,
455        ids: &[NodeId],
456        props: &[&str],
457    ) -> Result<Vec<Vec<serde_json::Value>>, Error> {
458        self.prop_columns
459            .with_fresh(&self.storage, |cols| cols.props_table(ids, props))?
460    }
461
462    /// Single-property column form of [`Graph::node_props_json_table`]:
463    /// `out[i]` is the value of `prop` on `ids[i]`, as one flat vector, so a
464    /// bulk single-property gather does not pay one row vector allocation per
465    /// id. A missing property reads as `Value::Null`; a nonexistent node is
466    /// [`Error::NodeNotFound`].
467    pub fn node_prop_json_column(
468        &self,
469        ids: &[NodeId],
470        prop: &str,
471    ) -> Result<Vec<serde_json::Value>, Error> {
472        self.prop_columns
473            .with_fresh(&self.storage, |cols| cols.prop_column(ids, prop))?
474    }
475
476    /// Group `ids` by the exact value of `prop` through the in-memory
477    /// property columns: one dense group code per id, plus one representative
478    /// value per code (the first occurrence). Null and missing property
479    /// values share one code represented by `Value::Null`; a nonexistent node
480    /// is [`Error::NodeNotFound`]. Codes are assigned under value identity,
481    /// which for the typed columns needs no per-row value materialization.
482    pub fn node_prop_group_codes(
483        &self,
484        ids: &[NodeId],
485        prop: &str,
486    ) -> Result<(Vec<u32>, Vec<serde_json::Value>), Error> {
487        self.prop_columns
488            .with_fresh(&self.storage, |cols| cols.group_codes(ids, prop))?
489    }
490
491    // ------------------------------------------------------------------
492    // Edge property columns
493    //
494    // The edge counterparts of the node column readers above, backed by an
495    // independent columnar cache over the `edges` sub-database. They let the
496    // query layer gather edge (relationship) properties in bulk through a
497    // dense-index read instead of an LMDB point lookup plus a msgpack decode
498    // per access. Semantics mirror the node methods exactly: a missing
499    // property reads as `Value::Null`; a nonexistent edge is
500    // [`Error::EdgeNotFound`].
501    // ------------------------------------------------------------------
502
503    /// Read one property of an edge through the in-memory edge property
504    /// columns. Returns `None` for a nonexistent edge and `Some(Value::Null)`
505    /// for a missing property.
506    pub fn edge_prop_json(
507        &self,
508        id: EdgeId,
509        prop: &str,
510    ) -> Result<Option<serde_json::Value>, Error> {
511        self.edge_columns.with_fresh(&self.storage, |cols| {
512            cols.id_to_dense.get(&id).map(|&d| {
513                cols.cols
514                    .get(prop)
515                    .and_then(|c| c.get_json_opt(d as usize))
516                    .unwrap_or(serde_json::Value::Null)
517            })
518        })
519    }
520
521    /// Bulk row-major gather of `props` for each edge id in `ids`.
522    pub fn edge_props_json_table(
523        &self,
524        ids: &[EdgeId],
525        props: &[&str],
526    ) -> Result<Vec<Vec<serde_json::Value>>, Error> {
527        self.edge_columns
528            .with_fresh(&self.storage, |cols| cols.props_table(ids, props))?
529    }
530
531    /// Single-property column gather for edges: `out[i]` is `prop` on `ids[i]`.
532    pub fn edge_prop_json_column(
533        &self,
534        ids: &[EdgeId],
535        prop: &str,
536    ) -> Result<Vec<serde_json::Value>, Error> {
537        self.edge_columns
538            .with_fresh(&self.storage, |cols| cols.prop_column(ids, prop))?
539    }
540
541    /// Group `ids` by the exact value of edge property `prop`: one dense group
542    /// code per id plus one representative value per code.
543    pub fn edge_prop_group_codes(
544        &self,
545        ids: &[EdgeId],
546        prop: &str,
547    ) -> Result<(Vec<u32>, Vec<serde_json::Value>), Error> {
548        self.edge_columns
549            .with_fresh(&self.storage, |cols| cols.group_codes(ids, prop))?
550    }
551
552    /// The minimum and maximum non-null value of one node property, from the
553    /// lazily computed statistics over the in-memory property columns.
554    /// `None` when the property has no typed column or no non-null values.
555    pub fn node_prop_min_max(
556        &self,
557        prop: &str,
558    ) -> Result<Option<(serde_json::Value, serde_json::Value)>, Error> {
559        self.prop_columns.with_fresh_mut(&self.storage, |cols| {
560            cols.prop_stats(prop)
561                .map(|s| (s.min.clone(), s.max.clone()))
562        })
563    }
564
565    /// Estimated fraction of non-null values of `prop` inside the given
566    /// bounds (either bound optional), from the property's equi-depth
567    /// histogram. `None` when no statistics exist for the property.
568    pub fn estimate_range_selectivity(
569        &self,
570        prop: &str,
571        lower: Option<&serde_json::Value>,
572        upper: Option<&serde_json::Value>,
573    ) -> Result<Option<f64>, Error> {
574        self.prop_columns.with_fresh_mut(&self.storage, |cols| {
575            cols.prop_stats(prop)
576                .map(|s| s.histogram.estimate_range_selectivity(lower, upper))
577        })
578    }
579
580    /// Estimated fraction of non-null values of `prop` equal to `val`: exact
581    /// for the property's most common values, histogram-estimated otherwise.
582    /// `None` when no statistics exist for the property.
583    pub fn estimate_equality_selectivity(
584        &self,
585        prop: &str,
586        val: &serde_json::Value,
587    ) -> Result<Option<f64>, Error> {
588        self.prop_columns.with_fresh_mut(&self.storage, |cols| {
589            cols.prop_stats(prop).map(|s| s.equality_selectivity(val))
590        })
591    }
592
593    /// Store an extension value (as `Arc`) keyed by its concrete type.
594    /// Replaces any existing value of the same type.
595    pub fn set_extension<T: Any + Send + Sync>(&self, val: Arc<T>) {
596        self.extensions
597            .lock()
598            .insert(StdTypeId::of::<T>(), Box::new(val));
599    }
600
601    /// Retrieve an `Arc` to a previously stored extension value, or `None` if absent.
602    pub fn get_extension<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
603        self.extensions
604            .lock()
605            .get(&StdTypeId::of::<T>())
606            .and_then(|b| b.downcast_ref::<Arc<T>>())
607            .cloned()
608    }
609
610    /// Return the extension of type `T`, initializing it with `init` if absent.
611    ///
612    /// `init` runs without the extensions lock held, so it may call back into
613    /// the graph (for example, to read from storage) without risking a lock
614    /// ordering problem. If two threads initialize concurrently, both may run
615    /// `init`, but only the first stored value is kept and every caller observes
616    /// that same `Arc`. `init` is fallible; on error nothing is stored and the
617    /// error is propagated.
618    pub fn get_or_init_extension_with<T, E, F>(&self, init: F) -> Result<Arc<T>, E>
619    where
620        T: Any + Send + Sync,
621        F: FnOnce() -> Result<Arc<T>, E>,
622    {
623        if let Some(existing) = self.get_extension::<T>() {
624            return Ok(existing);
625        }
626        let value = init()?;
627        let mut ext = self.extensions.lock();
628        // Another thread may have initialized while we built ours; prefer the
629        // already-stored value so all callers share one instance.
630        if let Some(existing) = ext
631            .get(&StdTypeId::of::<T>())
632            .and_then(|b| b.downcast_ref::<Arc<T>>())
633        {
634            return Ok(existing.clone());
635        }
636        ext.insert(StdTypeId::of::<T>(), Box::new(value.clone()));
637        Ok(value)
638    }
639
640    /// Execute a read-only transaction inside a closure.
641    pub fn view<F, T>(&self, f: F) -> Result<T, Error>
642    where
643        F: FnOnce(&ReadTxn) -> Result<T, Error>,
644    {
645        let rtxn = self.storage.env.read_txn()?;
646        let txn = ReadTxn { graph: self, rtxn };
647        f(&txn)
648    }
649
650    /// Execute a read-write transaction inside a closure.
651    pub fn update<F, T>(&self, f: F) -> Result<T, Error>
652    where
653        F: FnOnce(&mut WriteTxn) -> Result<T, Error>,
654    {
655        let _guard = self._write_lock.lock();
656        let wtxn = self.storage.env.write_txn()?;
657        let mut txn = WriteTxn {
658            graph: self,
659            wtxn,
660            mutations_count: 0,
661            delta: crate::csr::GraphDelta::default(),
662        };
663        match f(&mut txn) {
664            Ok(val) => {
665                let mutations_count = txn.mutations_count;
666                let delta = std::mem::take(&mut txn.delta);
667                txn.wtxn.commit()?;
668                if delta.force_full {
669                    self.prop_columns.record_force_full();
670                } else {
671                    self.prop_columns.record_touched_many(&delta.added_nodes);
672                    self.prop_columns.record_touched_many(&delta.updated_nodes);
673                }
674                // Edge columns: an edge removal (or a node deletion that may
675                // cascade to edges) reshuffles the dense edge mapping, so fall
676                // back to a full rebuild; otherwise patch the added edges in.
677                if delta.force_full || !delta.removed_edges.is_empty() {
678                    self.edge_columns.record_force_full();
679                } else {
680                    self.edge_columns.record_touched_many(&delta.added_edge_ids);
681                }
682                self.csr_cache.record_batch(delta);
683                if mutations_count > 0 {
684                    self.maybe_spawn_rebuild_n(mutations_count);
685                }
686                Ok(val)
687            }
688            Err(err) => {
689                txn.wtxn.abort();
690                Err(err)
691            }
692        }
693    }
694
695    /// Hold the write lock for the duration of `f`, executing `f` without
696    /// starting an LMDB transaction. Use this to make a multi-step read-then-write
697    /// sequence (such as MERGE) atomic with respect to other writers.
698    pub fn with_write_lock<F, R>(&self, f: F) -> R
699    where
700        F: FnOnce() -> R,
701    {
702        let _guard = self._write_lock.lock();
703        f()
704    }
705
706    /// Synchronously rebuild the CSR snapshot from LMDB. Useful after bulk
707    /// loads or when tests need a consistent read view before the threshold
708    /// has been crossed.
709    #[instrument(skip(self))]
710    pub fn rebuild_csr(&self) -> Result<(), Error> {
711        // Capture the generation before reading LMDB so writes that land during
712        // the build leave the snapshot conservatively stale.
713        let built_gen = self.csr_cache.current_gen();
714        // Clear the delta before reading LMDB: writes that commit during the
715        // build land in the emptied delta and are re-applied incrementally later
716        // (idempotently) rather than lost.
717        self.csr_cache.clear_delta();
718        let snap = CsrSnapshot::build(&self.storage)?;
719        let m = MatrixSet::materialize(
720            &snap,
721            self.n_threads.load(std::sync::atomic::Ordering::Acquire),
722        )?;
723        *self.matrices.write() = Some(m);
724        self.csr_cache.install_full(snap, built_gen);
725        Ok(())
726    }
727
728    /// Create a hot backup of this database to `destination`.
729    ///
730    /// `destination` is a **file path** for the backup snapshot (e.g.
731    /// `/backups/mydb_2026-05-27.mdb`). The file is a complete, portable
732    /// LMDB snapshot. Concurrent reads and writes are not blocked.
733    ///
734    /// To restore: create an empty directory, copy the snapshot file to
735    /// `<dir>/data.mdb`, then call `Graph::open(<dir>, map_size_gb)`.
736    pub fn backup(&self, destination: &Path) -> Result<(), Error> {
737        self.storage
738            .env
739            .copy_to_path(destination, heed::CompactionOption::Disabled)
740            .map(|_| ())
741            .map_err(Error::Storage)
742    }
743
744    /// Same as `backup` but compacts the database during the copy.
745    ///
746    /// The resulting file is smaller than a raw backup but the operation
747    /// takes longer because it rewrites every live page.
748    pub fn backup_compact(&self, destination: &Path) -> Result<(), Error> {
749        self.storage
750            .env
751            .copy_to_path(destination, heed::CompactionOption::Enabled)
752            .map(|_| ())
753            .map_err(Error::Storage)
754    }
755
756    /// Restore a backup snapshot created by `backup` or `backup_compact` into
757    /// a new database directory.
758    ///
759    /// Creates `dst_dir` if it does not exist, then copies `snapshot_file` into
760    /// `dst_dir/data.mdb`. After this call succeeds the caller can open the
761    /// restored database with `Graph::open(dst_dir, map_size_gb)`.
762    pub fn restore(snapshot_file: &Path, dst_dir: &Path) -> Result<(), Error> {
763        std::fs::create_dir_all(dst_dir)?;
764        let dst_file = dst_dir.join("data.mdb");
765        std::fs::copy(snapshot_file, &dst_file)?;
766        Ok(())
767    }
768}
769
770#[cfg(test)]
771mod extension_tests {
772    use std::sync::Arc;
773
774    use tempfile::TempDir;
775
776    use super::Graph;
777
778    fn open_tmp() -> (TempDir, Graph) {
779        let dir = TempDir::new().unwrap();
780        let g = Graph::open(dir.path(), 1).unwrap();
781        (dir, g)
782    }
783
784    /// Extensions are keyed by concrete type: a stored value round-trips, an
785    /// absent type returns `None`, and a second `set_extension` replaces the
786    /// previous value of the same type.
787    #[test]
788    fn extension_roundtrip_by_type() {
789        let (_dir, g) = open_tmp();
790        assert!(g.get_extension::<String>().is_none());
791
792        g.set_extension(Arc::new(String::from("cache")));
793        let got = g.get_extension::<String>().expect("extension must exist");
794        assert_eq!(*got, "cache");
795        assert!(g.get_extension::<u64>().is_none(), "distinct type slot");
796
797        g.set_extension(Arc::new(String::from("replaced")));
798        assert_eq!(*g.get_extension::<String>().unwrap(), "replaced");
799    }
800
801    /// `get_or_init_extension_with` runs `init` only when the slot is empty;
802    /// later callers observe the first stored value.
803    #[test]
804    fn get_or_init_extension_initializes_once() {
805        let (_dir, g) = open_tmp();
806
807        let v1 = g
808            .get_or_init_extension_with::<u64, std::convert::Infallible, _>(|| Ok(Arc::new(7)))
809            .unwrap();
810        assert_eq!(*v1, 7);
811
812        let v2 = g
813            .get_or_init_extension_with::<u64, std::convert::Infallible, _>(|| Ok(Arc::new(9)))
814            .unwrap();
815        assert_eq!(*v2, 7, "second init must not replace the stored value");
816    }
817
818    /// An `init` failure stores nothing, so a later successful `init` runs.
819    #[test]
820    fn get_or_init_extension_propagates_init_error() {
821        let (_dir, g) = open_tmp();
822
823        let err = g
824            .get_or_init_extension_with::<u64, &str, _>(|| Err("init failed"))
825            .unwrap_err();
826        assert_eq!(err, "init failed");
827        assert!(g.get_extension::<u64>().is_none());
828
829        let v = g
830            .get_or_init_extension_with::<u64, &str, _>(|| Ok(Arc::new(7)))
831            .unwrap();
832        assert_eq!(*v, 7);
833    }
834}
835
836#[cfg(test)]
837mod encode_tests {
838    use serde_json::json;
839
840    use super::{MAX_INDEXED_STRING_LEN, decode_property_value, encode_property_value};
841
842    /// A string up to the indexable bound encodes and round-trips; one byte over
843    /// the bound is declined so it never overflows the LMDB key size.
844    #[test]
845    fn over_long_strings_are_not_indexed() {
846        let at_limit = json!("a".repeat(MAX_INDEXED_STRING_LEN));
847        let encoded = encode_property_value(&at_limit).expect("at-limit string indexes");
848        assert_eq!(decode_property_value(&encoded), Some(at_limit));
849
850        let too_long = json!("a".repeat(MAX_INDEXED_STRING_LEN + 1));
851        assert_eq!(
852            encode_property_value(&too_long),
853            None,
854            "a string over the bound must not be indexed",
855        );
856    }
857
858    /// Distinct integers beyond 2^53 must encode to distinct keys. Encoding
859    /// purely through `f64` (the previous behavior) collapsed them, causing
860    /// index collisions and wrong `nodes_by_property` matches.
861    #[test]
862    fn large_integers_do_not_collide() {
863        let a = encode_property_value(&json!(9_007_199_254_740_992_i64)).unwrap(); // 2^53
864        let b = encode_property_value(&json!(9_007_199_254_740_993_i64)).unwrap(); // 2^53 + 1
865        assert_ne!(a, b, "distinct large integers must encode distinctly");
866    }
867
868    /// An integer and the float of the same real value must encode identically
869    /// so they keep comparing equal in the index (Cypher treats `30 = 30.0`).
870    #[test]
871    fn integer_and_equal_float_unify() {
872        assert_eq!(
873            encode_property_value(&json!(30)).unwrap(),
874            encode_property_value(&json!(30.0)).unwrap(),
875        );
876        assert_eq!(
877            encode_property_value(&json!(0)).unwrap(),
878            encode_property_value(&json!(0.0)).unwrap(),
879        );
880    }
881
882    /// Every numeric encoding must be the same length: property lookups match by
883    /// key prefix, so a value whose encoding prefixes another's would alias.
884    #[test]
885    fn numeric_encoding_is_fixed_length() {
886        for v in [
887            json!(1),
888            json!(-1),
889            json!(0),
890            json!(i64::MAX),
891            json!(i64::MIN),
892            json!(3.5),
893            json!(-2.5e10),
894        ] {
895            assert_eq!(encode_property_value(&v).unwrap().len(), 17, "value {v}");
896        }
897    }
898
899    /// Byte-lexicographic order of encodings must match numeric order, including
900    /// across the 2^53 boundary where the disambiguator orders the tie.
901    #[test]
902    fn numeric_ordering_preserved() {
903        let ascending: Vec<i64> = vec![
904            i64::MIN,
905            -1_000,
906            -1,
907            0,
908            1,
909            1_000,
910            1 << 53,
911            (1 << 53) + 1,
912            i64::MAX,
913        ];
914        let encoded: Vec<Vec<u8>> = ascending
915            .iter()
916            .map(|v| encode_property_value(&json!(v)).unwrap())
917            .collect();
918        let mut sorted = encoded.clone();
919        sorted.sort();
920        assert_eq!(encoded, sorted, "encodings must sort in numeric order");
921    }
922
923    /// Large integers must decode back to the exact integer, not a rounded float.
924    #[test]
925    fn decode_round_trips_large_integer() {
926        for v in [
927            json!(0),
928            json!(-1),
929            json!(9_007_199_254_740_993_i64),
930            json!(i64::MAX),
931        ] {
932            let enc = encode_property_value(&v).unwrap();
933            assert_eq!(decode_property_value(&enc), Some(v.clone()), "value {v}");
934        }
935    }
936}