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::{
16    csr::{CsrCache, CsrSnapshot},
17    error::Error,
18    schema::{
19        AdjEntry, DirectedNeighborEntry, EdgeId, EdgeRecord, LabelId, Language, NeighborEntry,
20        NodeId, NodeRecord, PropKeyId, PropValue, TypeId, WeightedPath,
21    },
22    storage::{
23        Storage, fts,
24        ids::{
25            adjust_label_count, adjust_type_count, alloc_edge_id, alloc_node_id, get_label,
26            get_or_create_label, get_or_create_prop_key, get_or_create_type, get_prop_key,
27            get_prop_key_name, get_type,
28        },
29        props,
30    },
31};
32
33pub mod algo;
34pub mod edge;
35pub mod fts_mod;
36pub mod index;
37pub mod kernels;
38pub mod node;
39pub mod stats;
40pub mod txn;
41pub mod vector;
42
43/// The direction of edges to count for degree centrality.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
45pub enum DegreeDirection {
46    /// Count incoming edges only.
47    In,
48    /// Count outgoing edges only.
49    Out,
50    /// Count both incoming and outgoing edges.
51    Both,
52}
53
54/// Which score [`Graph::link_prediction_score`] computes for a pair of nodes.
55///
56/// All five read the graph as undirected over distinct neighbors, the same
57/// neighborhood [`Graph::clustering_coefficient`] uses, so a pair joined by several
58/// edges is one neighbor and direction never matters. A higher score means the pair
59/// is more likely to become connected.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
61pub enum LinkPredictionMetric {
62    /// How many neighbors the two nodes share.
63    CommonNeighbors,
64    /// Shared neighbors over the size of the combined neighborhood, so a pair of
65    /// low-degree nodes is not penalized against a pair of hubs. Zero when neither
66    /// node has a neighbor.
67    Jaccard,
68    /// Shared neighbors weighted by `1 / ln(degree)`, so a neighbor that everyone
69    /// shares counts for little. A shared neighbor of degree one contributes nothing,
70    /// since `ln(1)` is zero and the term is undefined rather than large.
71    AdamicAdar,
72    /// Shared neighbors weighted by `1 / degree`, which penalizes popular neighbors
73    /// harder than Adamic-Adar does.
74    ResourceAllocation,
75    /// The product of the two degrees, on the theory that busy nodes attract more
76    /// edges. This one ignores shared neighbors entirely, so it scores pairs that
77    /// have nothing in common.
78    PreferentialAttachment,
79}
80
81/// Describes the pattern [`Graph::count_triangle_cycles`] counts, the directed
82/// cycle `(a)-[t1]->(b)-[t2]->(c)-[t3]->(a)` with an optional relationship
83/// type per hop and an optional label per node variable. `None` means
84/// unconstrained.
85#[derive(Debug, Clone, Default)]
86pub struct TriangleCountSpec<'a> {
87    /// Relationship types for the hops `a -> b`, `b -> c`, and `c -> a`.
88    pub rel_types: [Option<&'a str>; 3],
89    /// Labels required on `a`, `b`, and `c`.
90    pub labels: [Option<&'a str>; 3],
91}
92
93/// Describes the pattern [`Graph::count_linear_paths`] counts, an open directed
94/// path of one or two hops, `(v0)-[t1]->(v1)` or
95/// `(v0)-[t1]->(v1)-[t2]->(v2)`, with an optional relationship type per hop
96/// and an optional label per node variable. `None` means unconstrained.
97///
98/// `rel_types.len()` is the hop count (1 or 2); `labels.len()` is the node
99/// count (hop count plus one). The two-hop count follows Cypher MATCH
100/// relationship-uniqueness semantics: the two relationships must be distinct,
101/// which only constrains self-loop assignments where one edge could fill both
102/// hops.
103#[derive(Debug, Clone, Default)]
104pub struct PathCountSpec<'a> {
105    /// Relationship type per hop, in path order. Length 1 or 2.
106    pub rel_types: Vec<Option<&'a str>>,
107    /// Label per node variable, in path order. Length is `rel_types.len() + 1`.
108    pub labels: Vec<Option<&'a str>>,
109    /// Optional explicit allow-set of node ids per variable, in path order. A
110    /// `Some(ids)` entry restricts that variable to `ids` (intersected with its
111    /// label, if any); `None` leaves it unconstrained beyond the label. The
112    /// caller resolves these sets by pushing per-vertex property predicates down
113    /// into index lookups, so a filtered path count stays a kernel call instead
114    /// of materializing rows. An empty vector (the default) means no variable is
115    /// constrained, identical to the unfiltered path count.
116    pub vertex_allow: Vec<Option<Vec<NodeId>>>,
117}
118
119/// Describes the pattern [`Graph::grouped_edge_counts`] counts, typed edges
120/// grouped by one endpoint. With `group_is_dst`, edges are grouped by their
121/// destination and the source is the counted endpoint (in-degree per
122/// destination); otherwise edges are grouped by their source and the
123/// destination is counted (out-degree per source). `group_label` and
124/// `counted_label` optionally constrain each endpoint (`None` is
125/// unconstrained). `counted_nonnull_prop` counts an edge only when the counted
126/// endpoint's property is non-null (the semantics of `count(v.prop)` over the
127/// expansion); `None` counts every qualifying edge (the semantics of
128/// `count(*)` or `count(v)`, where a bound node variable is never null).
129#[derive(Debug, Clone, Default)]
130pub struct GroupedDegreeSpec<'a> {
131    /// Relationship type to count, or `None` for any type.
132    pub rel_type: Option<&'a str>,
133    /// Group by the edge destination (count incoming) when true; by the edge
134    /// source (count outgoing) when false.
135    pub group_is_dst: bool,
136    /// Label required on the group endpoint.
137    pub group_label: Option<&'a str>,
138    /// Label required on the counted endpoint.
139    pub counted_label: Option<&'a str>,
140    /// Explicit allow-set the counted endpoint must belong to, intersected with
141    /// `counted_label`; `None` leaves it unconstrained beyond the label. The
142    /// caller resolves this set by pushing a per-vertex property predicate down
143    /// into index lookups, as [`PathCountSpec::vertex_allow`] does, so a filtered
144    /// grouped count stays a kernel call. An empty slice counts zero.
145    pub counted_allow: Option<&'a [NodeId]>,
146    /// Property that must be non-null on the counted endpoint for an edge to
147    /// count; `None` counts every qualifying edge.
148    pub counted_nonnull_prop: Option<&'a str>,
149}
150
151/// Describes the pattern [`Graph::typed_neighbor_counts`] counts, the typed
152/// neighbors of each source across one hop. `incoming` follows incoming edges instead
153/// of outgoing ones. A neighbor qualifies when it carries every label in
154/// `neighbor_labels` (an empty slice is unconstrained) and, when
155/// `neighbor_allow` is present, is a member of that set; it adds to the counted
156/// total only when `neighbor_nonnull_prop` is absent or non-null on it (the
157/// semantics of `count(v.prop)` over the expansion, against `count(*)`).
158#[derive(Debug, Clone, Default)]
159pub struct NeighborCountSpec<'a> {
160    /// Relationship type to follow, or `None` for any type.
161    pub rel_type: Option<&'a str>,
162    /// Follow incoming edges (neighbors are edge sources) instead of outgoing.
163    pub incoming: bool,
164    /// Labels a neighbor must all carry to qualify.
165    pub neighbor_labels: &'a [&'a str],
166    /// Explicit allow-set a neighbor must belong to, intersected with the labels
167    /// above; `None` leaves the neighbor unconstrained beyond its labels. The
168    /// caller resolves this set by evaluating per-neighbor property predicates
169    /// itself, so a filtered count stays a kernel call instead of materializing
170    /// one entry per traversed edge, exactly as
171    /// [`PathCountSpec::vertex_allow`] does for the path count. An empty slice
172    /// admits no neighbor and counts zero.
173    pub neighbor_allow: Option<&'a [NodeId]>,
174    /// Property that must be non-null on a qualifying neighbor for it to add to
175    /// the counted total; `None` counts every qualifying neighbor.
176    pub neighbor_nonnull_prop: Option<&'a str>,
177}
178
179/// Builds a 12-byte composite key `(prefix u32 BE, id u64 BE)` for secondary index lookups.
180/// Decided `schema_has_edge` verdicts and the write generation they were decided
181/// under. A `None` value is a remembered "undecided", which is worth keeping so the
182/// probe budget is not respent to reach the same non-answer.
183pub(super) type SchemaProbeMemo = (u64, AHashMap<(LabelId, TypeId, LabelId), Option<bool>>);
184
185pub(super) fn composite_key(prefix: u32, id: u64) -> [u8; 12] {
186    let mut key = [0u8; 12];
187    key[..4].copy_from_slice(&prefix.to_be_bytes());
188    key[4..].copy_from_slice(&id.to_be_bytes());
189    key
190}
191
192/// Type tag for a null value in the sortable property encoding.
193pub(super) const ENCODED_NULL: u8 = 0x00;
194
195/// Sign bit mask used to make IEEE-754 `f64` bit patterns and two's-complement
196/// `i64` values sort in ascending numeric order as big-endian bytes.
197const SORT_SIGN_BIT: u64 = 0x8000_0000_0000_0000;
198
199/// Maximum string length (in bytes) that can be auto-indexed. The property
200/// index key is `(label_id, prop_key_id, encoded_val, node_id)`, so it carries
201/// 16 bytes of fixed fields plus the 2-byte string-encoding frame (`0x04` tag
202/// and `0x00` terminator) around the value. LMDB's default maximum key size is
203/// 511 bytes; a string longer than this would overflow that limit and cannot be
204/// indexed, so `encode_property_value` declines it and the value is left
205/// unindexed (equality lookups fall back to a scan, and long text belongs in a
206/// full-text index anyway). The bound is conservative to leave headroom.
207pub(super) const MAX_INDEXED_STRING_LEN: usize = 480;
208
209/// Encodes a JSON property value into a sortable byte representation for the index.
210///
211/// Numbers use a fixed 17-byte encoding: a `0x03` tag, then 8 bytes of the
212/// order-preserving `f64` bit pattern (the primary numeric sort key), then 8
213/// bytes of an integer disambiguator. The disambiguator makes the encoding
214/// lossless for `i64` values: two integers that round to the same `f64` (any
215/// pair beyond 2^53) still produce distinct keys, while an integer and a float
216/// of the same real value (e.g. `30` and `30.0`) produce identical keys so they
217/// continue to compare equal. Keeping every numeric encoding the same length is
218/// required because property lookups match by key prefix; a variable-length
219/// encoding where one value is a prefix of another would yield false matches.
220pub(super) fn encode_property_value(val: &serde_json::Value) -> Option<Vec<u8>> {
221    match val {
222        serde_json::Value::Null => Some(vec![ENCODED_NULL]),
223        serde_json::Value::Bool(false) => Some(vec![0x01]),
224        serde_json::Value::Bool(true) => Some(vec![0x02]),
225        serde_json::Value::Number(num) => {
226            let float_val = num.as_f64()?;
227            let bits = float_val.to_bits();
228            let masked = if (bits & SORT_SIGN_BIT) != 0 {
229                !bits
230            } else {
231                bits ^ SORT_SIGN_BIT
232            };
233            // Integer disambiguator: for any number whose exact real value is an
234            // integer in `i64` range, store that integer in sign-flipped
235            // big-endian order so distinct large integers never collide. All
236            // other numbers (non-integers, out-of-range) get a fixed sentinel;
237            // they already have a unique `f64` bit pattern in the primary key,
238            // so the sentinel value cannot affect ordering or equality.
239            let int_disambig: u64 = if let Some(i) = num.as_i64() {
240                (i as u64) ^ SORT_SIGN_BIT
241            } else if float_val.fract() == 0.0
242                && float_val >= i64::MIN as f64
243                && float_val <= i64::MAX as f64
244            {
245                ((float_val as i64) as u64) ^ SORT_SIGN_BIT
246            } else {
247                0
248            };
249            let mut buf = Vec::with_capacity(17);
250            buf.push(0x03);
251            buf.extend_from_slice(&masked.to_be_bytes());
252            buf.extend_from_slice(&int_disambig.to_be_bytes());
253            Some(buf)
254        }
255        serde_json::Value::String(s) => {
256            // A string too long to fit an LMDB key cannot be indexed; decline it
257            // so the property is left unindexed rather than crashing the write.
258            if s.len() > MAX_INDEXED_STRING_LEN {
259                return None;
260            }
261            let mut buf = Vec::with_capacity(1 + s.len() + 1);
262            buf.push(0x04);
263            buf.extend_from_slice(s.as_bytes());
264            buf.push(0x00);
265            Some(buf)
266        }
267        _ => None, // Skip arrays and objects
268    }
269}
270
271/// Comparable-type family of an encoded property value's leading type tag.
272/// Booleans span two tags (`0x01` false, `0x02` true) but form one comparable
273/// family; every other tag is its own family. Range scans compare only values
274/// within the bound's family, because under openCypher a value of one type
275/// never satisfies a range bound of another (a string is not comparable to a
276/// numeric bound), even though the tagged encoding orders them globally.
277pub(super) fn encoded_tag_family(tag: u8) -> u8 {
278    match tag {
279        0x02 => 0x01,
280        t => t,
281    }
282}
283
284/// Decodes a sortable byte representation back into a JSON property value.
285#[allow(dead_code)]
286pub(super) fn decode_property_value(bytes: &[u8]) -> Option<serde_json::Value> {
287    if bytes.is_empty() {
288        return None;
289    }
290    match bytes[0] {
291        0x00 => Some(serde_json::Value::Null),
292        0x01 => Some(serde_json::Value::Bool(false)),
293        0x02 => Some(serde_json::Value::Bool(true)),
294        0x03 => {
295            // Numbers are `tag + 8-byte f64 sort key + 8-byte int disambiguator`.
296            if bytes.len() < 17 {
297                return None;
298            }
299            // Prefer the lossless integer disambiguator when it round-trips,
300            // so large integers decode exactly rather than through `f64`.
301            let mut int_arr = [0u8; 8];
302            int_arr.copy_from_slice(&bytes[9..17]);
303            let int_val = (u64::from_be_bytes(int_arr) ^ SORT_SIGN_BIT) as i64;
304
305            let mut arr = [0u8; 8];
306            arr.copy_from_slice(&bytes[1..9]);
307            let masked = u64::from_be_bytes(arr);
308            let bits = if (masked & SORT_SIGN_BIT) == 0 {
309                !masked
310            } else {
311                masked ^ SORT_SIGN_BIT
312            };
313            let float_val = f64::from_bits(bits);
314
315            // If the disambiguator's integer equals the float key, the value was
316            // an integer (or integer-valued float): return it losslessly as an
317            // integer. Non-integers store a sentinel whose sign-flipped form is
318            // `i64::MIN`, which never matches a non-integer float key.
319            if (int_val as f64) == float_val {
320                Some(serde_json::Value::Number(int_val.into()))
321            } else {
322                serde_json::Number::from_f64(float_val).map(serde_json::Value::Number)
323            }
324        }
325        0x04 => {
326            let str_bytes = if bytes.ends_with(&[0x00]) {
327                &bytes[1..bytes.len() - 1]
328            } else {
329                &bytes[1..]
330            };
331            String::from_utf8(str_bytes.to_vec())
332                .ok()
333                .map(serde_json::Value::String)
334        }
335        _ => None,
336    }
337}
338
339/// Builds a composite key `(label_id, prop_key_id, encoded_val, node_id)` for node property index.
340pub(super) fn node_prop_index_key(
341    label_id: LabelId,
342    prop_key_id: PropKeyId,
343    encoded_val: &[u8],
344    node_id: NodeId,
345) -> Vec<u8> {
346    let mut key = Vec::with_capacity(4 + 4 + encoded_val.len() + 8);
347    key.extend_from_slice(&label_id.to_be_bytes());
348    key.extend_from_slice(&prop_key_id.to_be_bytes());
349    key.extend_from_slice(encoded_val);
350    key.extend_from_slice(&node_id.to_be_bytes());
351    key
352}
353
354/// Builds a composite key `(type_id, prop_key_id, encoded_val, edge_id)` for edge property index.
355pub(super) fn edge_prop_index_key(
356    type_id: TypeId,
357    prop_key_id: PropKeyId,
358    encoded_val: &[u8],
359    edge_id: EdgeId,
360) -> Vec<u8> {
361    let mut key = Vec::with_capacity(4 + 4 + encoded_val.len() + 8);
362    key.extend_from_slice(&type_id.to_be_bytes());
363    key.extend_from_slice(&prop_key_id.to_be_bytes());
364    key.extend_from_slice(encoded_val);
365    key.extend_from_slice(&edge_id.to_be_bytes());
366    key
367}
368
369/// Returns the trailing 8-byte id from a property-index key, but only when the
370/// key's encoded-value segment equals `encoded` exactly.
371///
372/// A property-index key is `(prefix u32, prop_key_id u32, encoded_val, id u64)`,
373/// so the value segment is `key[8 .. len - 8]`. A prefix scan on
374/// `(prefix, prop_key_id, encoded)` also matches keys whose value merely *starts*
375/// with `encoded`: for the NUL-terminated string encoding, a stored `"a\0"`
376/// (encoded `04 61 00 00`) is matched by a lookup for `"a"` (encoded `04 61 00`),
377/// because a small id has leading zero bytes. Requiring the value segment to
378/// equal `encoded` exactly rejects those collisions so equality lookups and
379/// unique-constraint checks never conflate distinct string values. Fixed-width
380/// encodings (numbers, bools, null) are already exact, so this never rejects a
381/// genuine match. Returns `None` when the key is too short or the value differs.
382pub(super) fn exact_prop_index_id(key: &[u8], encoded: &[u8]) -> Option<NodeId> {
383    if key.len() < 8 + 8 {
384        return None;
385    }
386    if &key[8..key.len() - 8] != encoded {
387        return None;
388    }
389    let id_bytes: [u8; 8] = key[key.len() - 8..].try_into().ok()?;
390    Some(u64::from_be_bytes(id_bytes))
391}
392
393/// Whether a stored string lies within the range `[lo, hi]` (or the open
394/// variants), using byte-wise comparison, which is the same order the
395/// order-preserving index encoding reproduces (`"a" < "a\0" < "ab"`). A `None`
396/// bound is unbounded on that side. Backs the string-range label-scan fallback
397/// for values too long to index.
398pub(super) fn str_in_range(
399    s: &str,
400    lo: Option<&str>,
401    lo_inclusive: bool,
402    hi: Option<&str>,
403    hi_inclusive: bool,
404) -> bool {
405    if let Some(lo) = lo {
406        if lo_inclusive {
407            if s < lo {
408                return false;
409            }
410        } else if s <= lo {
411            return false;
412        }
413    }
414    if let Some(hi) = hi {
415        if hi_inclusive {
416            if s > hi {
417                return false;
418            }
419        } else if s >= hi {
420            return false;
421        }
422    }
423    true
424}
425
426/// Builds a composite key `(label_id, prop_key_id, term)` for FTS postings.
427pub(super) fn fts_postings_key(label_id: LabelId, prop_key_id: PropKeyId, term: &str) -> Vec<u8> {
428    let mut key = Vec::with_capacity(8 + term.len());
429    key.extend_from_slice(&label_id.to_be_bytes());
430    key.extend_from_slice(&prop_key_id.to_be_bytes());
431    key.extend_from_slice(term.as_bytes());
432    key
433}
434
435/// Builds a 12-byte FTS posting value `(node_id, frequency)`.
436pub(super) fn fts_posting_val(node_id: NodeId, frequency: u32) -> [u8; 12] {
437    let mut val = [0u8; 12];
438    val[0..8].copy_from_slice(&node_id.to_be_bytes());
439    val[8..12].copy_from_slice(&frequency.to_be_bytes());
440    val
441}
442
443/// Parses a 12-byte FTS posting value into `(node_id, frequency)`.
444pub(super) fn parse_fts_posting_val(bytes: &[u8]) -> Result<(NodeId, u32), Error> {
445    if bytes.len() != 12 {
446        return Err(Error::Corrupt("fts posting value must be 12 bytes"));
447    }
448    let node_id = NodeId::from_be_bytes(
449        bytes[0..8]
450            .try_into()
451            .map_err(|_| Error::Corrupt("fts posting: node_id slice wrong size"))?,
452    );
453    let frequency = u32::from_be_bytes(
454        bytes[8..12]
455            .try_into()
456            .map_err(|_| Error::Corrupt("fts posting: frequency slice wrong size"))?,
457    );
458    Ok((node_id, frequency))
459}
460
461/// Builds a 16-byte FTS doc key `(label_id, prop_key_id, node_id)`.
462pub(super) fn fts_doc_key(label_id: LabelId, prop_key_id: PropKeyId, node_id: NodeId) -> [u8; 16] {
463    let mut key = [0u8; 16];
464    key[0..4].copy_from_slice(&label_id.to_be_bytes());
465    key[4..8].copy_from_slice(&prop_key_id.to_be_bytes());
466    key[8..16].copy_from_slice(&node_id.to_be_bytes());
467    key
468}
469
470/// Parses a 4-byte doc length value.
471pub(super) fn parse_fts_doc_val(bytes: &[u8]) -> Result<u32, Error> {
472    if bytes.len() != 4 {
473        return Err(Error::Corrupt("fts doc val must be 4 bytes"));
474    }
475    Ok(u32::from_be_bytes(bytes.try_into().map_err(|_| {
476        Error::Corrupt("fts doc val: slice wrong size")
477    })?))
478}
479
480pub(super) fn fts_stats_n_key(label_id: LabelId, prop_key_id: PropKeyId) -> String {
481    format!("fts_stats:node:l:{label_id}:p:{prop_key_id}:N")
482}
483
484pub(super) fn fts_stats_sum_dl_key(label_id: LabelId, prop_key_id: PropKeyId) -> String {
485    format!("fts_stats:node:l:{label_id}:p:{prop_key_id}:sum_dl")
486}
487
488/// The graph database handle. It is cheap to clone, since all state is behind `Arc`.
489#[derive(Clone)]
490pub struct Graph {
491    pub(super) storage: Arc<Storage>,
492    pub(super) _write_lock: Arc<ReentrantMutex<()>>,
493    pub(super) csr_cache: Arc<CsrCache>,
494    pub(super) prop_columns: Arc<crate::columns::ColumnsCache<crate::columns::NodeSource>>,
495    pub(super) edge_columns: Arc<crate::columns::ColumnsCache<crate::columns::EdgeSource>>,
496    /// Per-`(label, type)` edge frequencies backing the optimizer's per-source-label
497    /// expand-ratio estimate. Never built as a side effect of a query; see
498    /// [`crate::graph::stats`] for which reader tolerates which staleness.
499    pub(super) edge_fanout: Arc<parking_lot::Mutex<Option<crate::graph::stats::EdgeFanout>>>,
500    /// Decided `schema_has_edge` verdicts for one write generation, keyed by
501    /// `(src_label, type, dst_label)`. The type-inference pass asks the same questions
502    /// on every execution because there is no plan cache, and answering without the
503    /// statistics table means walking the graph, so a decided verdict is remembered
504    /// until a write invalidates the generation. See [`crate::graph::stats`].
505    pub(super) schema_probes: Arc<parking_lot::Mutex<SchemaProbeMemo>>,
506    /// Cached id-indexed group codes, one shared array per grouped property,
507    /// valid for exactly one write generation, which is what lets a grouped
508    /// bulk aggregation read one array cell per row instead of interning one
509    /// value per row per query. See [`crate::columns::IdGroupCodes`].
510    pub(super) group_codes_by_id: Arc<parking_lot::Mutex<crate::columns::IdGroupCodesCache>>,
511    /// Cached full label scans for the committed-read path, one shared sorted id
512    /// vector per label, valid for exactly one write generation. Filters, the
513    /// vectorized executor, and the counting kernels each enumerate a whole
514    /// label per query, and with no plan cache the same label is rescanned
515    /// through LMDB on every execution; this pins that scan until a committed
516    /// write moves the generation. Transaction-scoped label reads bypass it,
517    /// because an open write transaction must see its own uncommitted labels.
518    pub(super) label_scans: Arc<parking_lot::Mutex<index::LabelScanCache>>,
519    pub(super) n_threads: Arc<std::sync::atomic::AtomicI32>,
520    /// Type-erased extension cache. Higher-level crates attach caches (e.g. the
521    /// HNSW vector index) to a Graph without creating a circular dependency,
522    /// through the `get_extension`, `set_extension`, and
523    /// `get_or_init_extension_with` methods. Keys are `std::any::TypeId`; values
524    /// are `Arc<dyn Any + Send + Sync>`.
525    pub(crate) extensions: Arc<parking_lot::Mutex<AHashMap<StdTypeId, Box<dyn Any + Send + Sync>>>>,
526    /// Test-only injection points; see [`TestHooks`]. Never compiled into a
527    /// release build.
528    #[cfg(test)]
529    pub(super) test_hooks: Arc<TestHooks>,
530}
531
532/// One test-only injection point: a closure the test installs, fired at most
533/// once at its call site.
534#[cfg(test)]
535pub(super) type HookSlot = parking_lot::Mutex<Option<Box<dyn Fn() + Send>>>;
536
537/// Test-only injection points for the race-condition tests. Instance-scoped,
538/// so parallel tests over their own `TempDir` graphs cannot interfere. Each
539/// hook fires at most once: [`TestHooks::fire`] takes the closure out before
540/// calling it, so a hook that writes back into the graph cannot re-trigger
541/// itself, and later passes through the same site run unhooked.
542#[cfg(test)]
543#[derive(Default)]
544pub(super) struct TestHooks {
545    /// Fires inside [`Graph::update`] after `commit_and_publish` and before
546    /// the column bookkeeping, while the write lock is still held. This is
547    /// the window the columns stamp race needs: the persisted generation has
548    /// moved, and the touched ids are not yet in the pending buffer.
549    pub(super) after_commit_before_column_bookkeeping: HookSlot,
550    /// Fires inside [`Graph::schema_has_edge`] after the probe computes its
551    /// verdict and before `memoize_schema_probe`. This is the window the memo
552    /// race needs: a write committing here makes the verdict describe
553    /// pre-commit state.
554    pub(super) before_schema_memoize: HookSlot,
555}
556
557#[cfg(test)]
558impl TestHooks {
559    pub(super) fn fire(slot: &HookSlot) {
560        let hook = slot.lock().take();
561        if let Some(hook) = hook {
562            hook();
563        }
564    }
565}
566
567/// A read-only transaction on the graph.
568pub struct ReadTxn<'a> {
569    pub(super) graph: &'a Graph,
570    pub(super) rtxn: crate::storage::OwnedRoTxn<'a>,
571}
572
573/// A read-write transaction on the graph.
574pub struct WriteTxn<'a> {
575    pub(super) graph: &'a Graph,
576    pub(super) wtxn: crate::storage::RwTxn<'a>,
577    pub(super) mutations_count: usize,
578    /// Structural mutations staged during this transaction, flushed to the
579    /// `CsrCache` only on commit so an aborted transaction records nothing.
580    pub(super) delta: crate::csr::GraphDelta,
581    /// Per-transaction memo for work that is identical across the records of one
582    /// batch. See [`WriteBatchCache`].
583    pub(super) cache: WriteBatchCache,
584}
585
586/// Holds the answers that stay true for the whole of one write transaction, so
587/// that a bulk write pays for them once instead of once per record.
588///
589/// A batch of a million edges asked the same three questions a million times:
590/// what integer is this relationship type (a `format!` and a `meta` lookup),
591/// which property indexes are active for it (a `format!` and a `meta` prefix
592/// scan, paid even when there are none, which is the common case), and does this
593/// endpoint exist (a lookup in a tree the size of the graph). Measured against
594/// the storage layer, those and the id allocation were most of an edge insert:
595/// the four LMDB writes an edge performs total about 1.1 µs against a measured
596/// 4.9 µs per edge.
597///
598/// Every entry is safe for exactly one transaction and no longer. There is one
599/// writer at a time, so nothing else can change a registry or an index
600/// definition underneath this; what this transaction changes itself, it records
601/// here too. The endpoint memo is the one that can go stale from inside, since a
602/// node deleted later in the same transaction must stop counting as present, so
603/// a delete clears it.
604#[derive(Default)]
605pub(super) struct WriteBatchCache {
606    /// Relationship type name to id, including types created by this
607    /// transaction.
608    types: AHashMap<String, TypeId>,
609    /// Active edge property indexes per type, as `get_active_edge_indexes`
610    /// returns them.
611    edge_indexes: AHashMap<TypeId, Vec<(PropKeyId, u8)>>,
612    /// Node ids this transaction has already proved exist. It holds one id per
613    /// distinct endpoint the transaction touches and is released only with the
614    /// transaction, so a million-node bulk load carries roughly 18 MB of it.
615    known_nodes: AHashSet<NodeId>,
616}
617
618impl WriteBatchCache {
619    fn knows_node(&self, id: NodeId) -> bool {
620        self.known_nodes.contains(&id)
621    }
622
623    fn remember_node(&mut self, id: NodeId) {
624        self.known_nodes.insert(id);
625    }
626
627    fn type_id(&self, name: &str) -> Option<TypeId> {
628        self.types.get(name).copied()
629    }
630
631    fn remember_type(&mut self, name: &str, id: TypeId) {
632        self.types.insert(name.to_string(), id);
633    }
634
635    /// Returns the active edge indexes for `type_id`, computing them with `f` on
636    /// the first ask.
637    pub(super) fn edge_indexes_or_insert<E>(
638        &mut self,
639        type_id: TypeId,
640        f: impl FnOnce() -> Result<Vec<(PropKeyId, u8)>, E>,
641    ) -> Result<&[(PropKeyId, u8)], E> {
642        if !self.edge_indexes.contains_key(&type_id) {
643            let computed = f()?;
644            self.edge_indexes.insert(type_id, computed);
645        }
646        Ok(&self.edge_indexes[&type_id])
647    }
648
649    /// Forgets the endpoint memo. Called by any node deletion, since a node this
650    /// transaction removes must stop satisfying a later edge's existence check.
651    /// It drops every entry rather than the deleted id alone, so a batch that
652    /// interleaves deletions re-proves each endpoint against storage.
653    pub(super) fn invalidate_nodes(&mut self) {
654        self.known_nodes.clear();
655    }
656}
657
658thread_local! {
659    /// Identity of the LMDB environment whose `Graph::update` closure this
660    /// thread is currently inside (0 when none). LMDB permits only one active
661    /// writer transaction per environment; a stray call to an auto-committing
662    /// `Graph` mutation method on the SAME environment (which opens its own
663    /// writer transaction) while this is set would block forever on the
664    /// writer lock `Graph::update` already holds. Keyed by environment so
665    /// mutating a different, independent `Graph` inside the closure (a safe
666    /// pattern, e.g. copying between databases) does not trip the assert.
667    /// Checked at the top of every auto-committing mutation method and of
668    /// `Graph::update` itself, so a missed conversion to the `WriteTxn`-based
669    /// method (or a nested `update` on the same graph) becomes an immediate,
670    /// precisely located debug-build panic instead of a silent hang.
671    static IN_WRITE_TXN: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
672}
673
674struct WriteTxnGuard {
675    previous: usize,
676}
677
678impl WriteTxnGuard {
679    fn enter(env_id: usize) -> Self {
680        let previous = IN_WRITE_TXN.with(|f| f.replace(env_id));
681        WriteTxnGuard { previous }
682    }
683}
684
685impl Drop for WriteTxnGuard {
686    fn drop(&mut self) {
687        IN_WRITE_TXN.with(|f| f.set(self.previous));
688    }
689}
690
691impl Graph {
692    /// A stable per-environment identity for the deadlock tripwire.
693    fn write_txn_env_id(&self) -> usize {
694        Arc::as_ptr(&self.storage) as usize
695    }
696
697    fn debug_assert_not_in_write_txn(&self) {
698        debug_assert!(
699            IN_WRITE_TXN.with(|f| f.get()) != self.write_txn_env_id(),
700            "an auto-committing Graph method (or a nested Graph::update) was called while a \
701             WriteTxn from Graph::update was already open on this graph on this thread; call \
702             the WriteTxn method instead to avoid a same-thread deadlock on LMDB's \
703             single-writer lock"
704        );
705    }
706}
707
708impl Graph {
709    /// Open (creating if absent) the database at `path`.
710    ///
711    /// `map_size_gb` is the size of the LMDB memory map, and it is an upper bound
712    /// on how large the database may grow for the lifetime of this handle, not an
713    /// allocation: LMDB reserves the address range and commits pages as they are
714    /// written, so a large value costs virtual address space rather than disk or
715    /// RAM. There is no resize path. Once the data exceeds the bound, every write
716    /// fails with the underlying `MDB_MAP_FULL` through [`Error::Storage`] until
717    /// the database is reopened with a larger value, which is safe to do and keeps
718    /// the existing data. Size it for the eventual database, not the current one.
719    ///
720    /// Opening builds none of the derived structures; see the comment inside.
721    pub fn open(path: &Path, map_size_gb: usize) -> Result<Self, Error> {
722        let storage = Storage::open(path, map_size_gb)?;
723        // Older versions persisted the CSR snapshot next to the LMDB files but
724        // never read it back; remove the stale artifact if one is present.
725        let _ = std::fs::remove_file(path.join("csr_snapshot.bin"));
726        let storage = Arc::new(storage);
727        // Opening builds nothing. The CSR snapshot is built by the freshness gate
728        // (`ensure_snapshot_fresh`) when a consumer that needs it first runs, and
729        // every such consumer already calls that gate. Building it here instead cost
730        // a full edge scan on every open, which is time a workload of point lookups,
731        // property reads, or point adjacency never uses: those paths read LMDB
732        // directly. On a large database that eager work dominated the whole session's
733        // latency, and it was repaid on every reopen.
734        let csr_cache = Arc::new(CsrCache::new_unbuilt());
735        Ok(Self {
736            storage,
737            _write_lock: Arc::new(ReentrantMutex::new(())),
738            csr_cache,
739            prop_columns: Arc::new(crate::columns::ColumnsCache::default()),
740            edge_columns: Arc::new(crate::columns::ColumnsCache::default()),
741            edge_fanout: Arc::new(parking_lot::Mutex::new(None)),
742            schema_probes: Arc::new(parking_lot::Mutex::new((0, AHashMap::new()))),
743            group_codes_by_id: Arc::new(parking_lot::Mutex::new(
744                crate::columns::IdGroupCodesCache::default(),
745            )),
746            label_scans: Arc::new(parking_lot::Mutex::new(index::LabelScanCache::default())),
747            n_threads: Arc::new(std::sync::atomic::AtomicI32::new(0)),
748            extensions: Arc::new(parking_lot::Mutex::new(AHashMap::new())),
749            #[cfg(test)]
750            test_hooks: Arc::new(TestHooks::default()),
751        })
752    }
753
754    /// Set the thread count for the parallel read passes, overriding the
755    /// `ISSUNDB_NUM_THREADS` environment variable. Set to 0 to restore the default
756    /// behavior, which resolves through `threads::resolve`: `ISSUNDB_NUM_THREADS`,
757    /// then `OMP_NUM_THREADS`, then the machine's parallelism.
758    ///
759    /// Every parallel consumer (the counting kernels and the analytics passes that
760    /// split over nodes or sources) shares that resolution, so this one knob has one
761    /// meaning. There is no pool to configure: each pass resolves the budget when it
762    /// starts and spawns scoped threads for its own duration, so a call here takes
763    /// effect on the next pass and never fails.
764    pub fn set_thread_count(&self, n: i32) -> Result<(), Error> {
765        self.n_threads
766            .store(n, std::sync::atomic::Ordering::Release);
767        Ok(())
768    }
769
770    /// Read one property of a node as the `serde_json::Value` that decoding the
771    /// stored record would give. Returns `None` for a nonexistent node and
772    /// `Some(Value::Null)` for a missing property. Either way the result
773    /// reflects committed state.
774    ///
775    /// Served through the in-memory property columns once they exist, refreshing
776    /// them against pending writes first; while they are absent the read goes
777    /// straight to storage instead of building them (see
778    /// [`crate::columns::ColumnsCache::should_serve_directly`]).
779    pub fn node_prop_json(
780        &self,
781        id: NodeId,
782        prop: &str,
783    ) -> Result<Option<serde_json::Value>, Error> {
784        // One property of one node is an LMDB point read. Serving it by building
785        // every column costs a full node scan, which is the wrong trade for a
786        // point query; the read only goes through the columns once they exist,
787        // or once enough direct reads have amortized building them.
788        if self.prop_columns.should_serve_directly(1) {
789            let Some(obj) = self.direct_node_props(id)? else {
790                return Ok(None);
791            };
792            return Ok(Some(
793                obj.get(prop).cloned().unwrap_or(serde_json::Value::Null),
794            ));
795        }
796        self.prop_columns.with_fresh(&self.storage, |cols| {
797            cols.id_to_dense.get(&id).map(|&d| {
798                cols.cols
799                    .get(prop)
800                    .and_then(|c| c.get_json_opt(d as usize))
801                    .unwrap_or(serde_json::Value::Null)
802            })
803        })
804    }
805
806    /// Gathers `props` for each id in `ids` through the in-memory property columns,
807    /// the bulk form of [`Graph::node_prop_json`], row-major (`out[i][j]` is
808    /// `props[j]` on `ids[i]`). One columns refresh covers the whole gather,
809    /// and each id resolves to its dense index once. A missing property reads
810    /// as `Value::Null`; a nonexistent node is [`Error::NodeNotFound`].
811    pub fn node_props_json_table(
812        &self,
813        ids: &[NodeId],
814        props: &[&str],
815    ) -> Result<Vec<Vec<serde_json::Value>>, Error> {
816        if self.prop_columns.should_serve_directly(ids.len()) {
817            // One transaction for the whole gather, so the request is a single
818            // point in time and pays one begin/end pair rather than one per id.
819            return self
820                .direct_node_props_many(ids)?
821                .into_iter()
822                .zip(ids)
823                .map(|(obj, &id)| {
824                    let obj = obj.ok_or(Error::NodeNotFound(id))?;
825                    Ok(props
826                        .iter()
827                        .map(|p| obj.get(*p).cloned().unwrap_or(serde_json::Value::Null))
828                        .collect())
829                })
830                .collect();
831        }
832        self.prop_columns
833            .with_fresh(&self.storage, |cols| cols.props_table(ids, props))?
834    }
835
836    /// Gathers one property as a flat column, the single-property form of
837    /// [`Graph::node_props_json_table`]. `out[i]` is the value of `prop` on `ids[i]`, so a
838    /// bulk single-property gather does not pay one row vector allocation per
839    /// id. A missing property reads as `Value::Null`; a nonexistent node is
840    /// [`Error::NodeNotFound`].
841    pub fn node_prop_json_column(
842        &self,
843        ids: &[NodeId],
844        prop: &str,
845    ) -> Result<Vec<serde_json::Value>, Error> {
846        if self.prop_columns.should_serve_directly(ids.len()) {
847            return self
848                .direct_node_props_many(ids)?
849                .into_iter()
850                .zip(ids)
851                .map(|(obj, &id)| {
852                    let obj = obj.ok_or(Error::NodeNotFound(id))?;
853                    Ok(obj.get(prop).cloned().unwrap_or(serde_json::Value::Null))
854                })
855                .collect();
856        }
857        self.prop_columns
858            .with_fresh(&self.storage, |cols| cols.prop_column(ids, prop))?
859    }
860
861    /// One node's user properties decoded from storage, the way the column
862    /// build decodes them, so a gather served directly and the same gather
863    /// served through the columns cannot disagree. `None` if the node is gone.
864    fn direct_node_props(&self, id: NodeId) -> Result<Option<serde_json::Value>, Error> {
865        <crate::columns::NodeSource as crate::columns::ColumnSource>::fetch_one(&self.storage, id)
866    }
867
868    /// [`Graph::direct_node_props`] for many ids under one transaction, in input
869    /// order. `None` for a node that is gone.
870    fn direct_node_props_many(
871        &self,
872        ids: &[NodeId],
873    ) -> Result<Vec<Option<serde_json::Value>>, Error> {
874        <crate::columns::NodeSource as crate::columns::ColumnSource>::fetch_many(&self.storage, ids)
875    }
876
877    /// Whether each of `ids` carries a non-null value for `prop`, in input order.
878    ///
879    /// A node that is not there reads as absent rather than raising, because the
880    /// callers' ids come from the CSR snapshot, which can lag a deletion; treating
881    /// the gap as a null value is what the row pipeline would effectively produce
882    /// for a row a stale snapshot should no longer have offered.
883    ///
884    /// Honors the same small-request path the property gathers do, so resolving
885    /// presence for a handful of nodes costs a handful of point reads instead of
886    /// one full scan to build every column. That is the whole point of it existing
887    /// separately: the counting kernels need presence for the neighbors they
888    /// actually visit, not a dense mask over the entire graph.
889    pub(super) fn nodes_prop_present(
890        &self,
891        ids: &[NodeId],
892        prop: &str,
893    ) -> Result<Vec<bool>, Error> {
894        if self.prop_columns.should_serve_directly(ids.len()) {
895            return Ok(self
896                .direct_node_props_many(ids)?
897                .into_iter()
898                .map(|obj| obj.is_some_and(|o| o.get(prop).is_some_and(|v| !v.is_null())))
899                .collect());
900        }
901        self.prop_columns.with_fresh(&self.storage, |cols| {
902            ids.iter()
903                .map(|id| match (cols.id_to_dense.get(id), cols.cols.get(prop)) {
904                    (Some(&d), Some(col)) => col.is_present(d as usize),
905                    // Either the columns never saw this entity or no such property
906                    // exists anywhere; both read as null.
907                    _ => false,
908                })
909                .collect()
910        })
911    }
912
913    /// Evaluate `prop <op> rhs` for each of `ids` directly against the typed
914    /// in-memory property column, one keep flag per id in input order, without
915    /// materializing a `Value` per row. The semantics are exactly the outcome a
916    /// Cypher comparison filter keeps a row on; see
917    /// [`crate::columns::PropColumns::cmp_mask`] for the three rules. A
918    /// nonexistent node is [`Error::NodeNotFound`].
919    ///
920    /// `Ok(None)` declines, and the caller falls back to gathering and
921    /// comparing boxed values: a small request on a cold graph must not build
922    /// every column (the same size test the property gathers apply), and a
923    /// mixed-kind `Json` fallback column has no typed storage to compare
924    /// against.
925    pub fn nodes_prop_cmp_mask(
926        &self,
927        ids: &[NodeId],
928        prop: &str,
929        op: crate::columns::PropCmp,
930        rhs: &serde_json::Value,
931    ) -> Result<Option<Vec<bool>>, Error> {
932        if self.prop_columns.should_serve_directly(ids.len()) {
933            return Ok(None);
934        }
935        self.prop_columns
936            .with_fresh(&self.storage, |cols| cols.cmp_mask(ids, prop, op, rhs))?
937    }
938
939    /// Build the in-memory property columns now, if they are not built already.
940    ///
941    /// Every reader either serves a small request without them or, for the advisory
942    /// statistics, declines rather than pay for them, so nothing builds them as a
943    /// side effect of a small workload. That is deliberate: the build is one full
944    /// entity scan, and it used to dominate cold-start latency. This is the
945    /// deliberate way to ask for it, for a caller that wants the optimizer's
946    /// selectivity estimates and zone-map pruning available on a cold graph, or that
947    /// would rather pay the scan once up front than have a later bulk read pay it.
948    ///
949    /// It replaces an accident: `node_prop_group_codes` used to build
950    /// unconditionally, so "call it and discard the result" was the idiom for
951    /// warming the columns. Grouping now follows the same size test as the other
952    /// readers, and warming them is this call.
953    /// It is also the columns cache file's save site (the counterpart of
954    /// `rebuild_csr` for the CSR cache file): materializing persists the built
955    /// set next to the LMDB files, so a later process loads it instead of
956    /// scanning, and a repeat at an unchanged generation rewrites nothing. No
957    /// lazy build saves, so a read-only workload never writes a file as a side
958    /// effect of a query.
959    pub fn materialize_property_columns(&self) -> Result<(), Error> {
960        #[cfg(feature = "lmdb")]
961        {
962            // Captured before the build, and under the write lock. Every
963            // mutation holds that lock from before its commit until after it
964            // records its touched ids. So at the capture, every commit the
965            // stamp counts has already recorded its delta, and the drain
966            // inside `with_fresh` absorbs all of them. That is the invariant
967            // the saved file needs: stamp <= absorbed content. A write landing
968            // after the capture leaves the file stale, which is safe. Reading
969            // the generation without the lock is not: it could see a commit
970            // whose touched ids were not yet recorded, stamp the file one
971            // generation ahead of its content, and a later process would load
972            // stale values as fresh.
973            let persisted_gen = {
974                let _guard = self._write_lock.lock();
975                let rtxn = self.storage.env.read_txn()?;
976                crate::storage::ids::commit_gen(&self.storage, &rtxn)?
977            };
978            let _quiet = crate::columns::MaterializingColumns::install();
979            self.prop_columns.with_fresh(&self.storage, |cols| {
980                let _ = crate::cache_file::save_columns(&self.storage, cols, persisted_gen);
981            })
982        }
983        #[cfg(not(feature = "lmdb"))]
984        {
985            let _quiet = crate::columns::MaterializingColumns::install();
986            self.prop_columns.with_fresh(&self.storage, |_| ())
987        }
988    }
989
990    /// Group `ids` by the exact value of `prop` through the in-memory
991    /// property columns: one dense group code per id, plus one representative
992    /// value per code (the first occurrence). Null and missing property
993    /// values share one code represented by `Value::Null`; a nonexistent node
994    /// is [`Error::NodeNotFound`]. Codes are assigned under value identity,
995    /// which for the typed columns needs no per-row value materialization.
996    pub fn node_prop_group_codes(
997        &self,
998        ids: &[NodeId],
999        prop: &str,
1000    ) -> Result<(Vec<u32>, Vec<serde_json::Value>), Error> {
1001        // A small request is grouped over an ephemeral column set built from just
1002        // those nodes, rather than by building every column from a full scan.
1003        // Grouping is a bulk read only when the id set is bulk; a grouped count
1004        // whose groups are a handful of nodes was paying one full node scan with a
1005        // decode per node, which is the cold-start cost the small-gather path exists
1006        // to avoid, and the caller had no way to opt out.
1007        //
1008        // The ephemeral set goes through the same `from_items` and `group_codes` as
1009        // the shared one, so this is the same grouping code over a narrower
1010        // population, not a second implementation of it. A node that is gone is left
1011        // out, which makes `group_codes` report `NodeNotFound` for it exactly as the
1012        // shared columns would.
1013        if self.prop_columns.should_serve_directly(ids.len()) {
1014            let fetched = self.direct_node_props_many(ids)?;
1015            let items: Vec<(NodeId, serde_json::Value)> = ids
1016                .iter()
1017                .zip(fetched)
1018                .filter_map(|(&id, obj)| obj.map(|o| (id, o)))
1019                .collect();
1020            // Only the grouped property is columnarized; the rest would be built
1021            // and dropped.
1022            let cols = crate::columns::PropColumns::<crate::columns::NodeSource>::from_items_for(
1023                items,
1024                Some(prop),
1025            );
1026            return cols.group_codes(ids, prop);
1027        }
1028        self.prop_columns
1029            .with_fresh(&self.storage, |cols| cols.group_codes(ids, prop))?
1030    }
1031
1032    /// The id-indexed form of [`Graph::node_prop_group_codes`], one shared
1033    /// array over every node, `codes[node_id]` the node's group code under
1034    /// exact value identity and [`crate::columns::ID_GROUP_ABSENT`] where no
1035    /// such node exists, plus one representative value per code. Cached per
1036    /// write generation and shared, so a grouped bulk aggregation pays the
1037    /// value interning once per generation and one array read per row per
1038    /// query afterward. Building it costs one pass over every node (and the
1039    /// full column build when the columns are absent), so a caller with a
1040    /// small row set wants [`Graph::node_prop_group_codes`] instead.
1041    pub fn node_prop_group_codes_by_id(
1042        &self,
1043        prop: &str,
1044    ) -> Result<std::sync::Arc<crate::columns::IdGroupCodes>, Error> {
1045        let mut cache = self.group_codes_by_id.lock();
1046        let generation = self.csr_cache.current_gen();
1047        if cache.generation != generation {
1048            cache.by_prop.clear();
1049            cache.generation = generation;
1050        }
1051        if let Some(hit) = cache.by_prop.get(prop) {
1052            return Ok(hit.clone());
1053        }
1054        let built = self.prop_columns.with_fresh(&self.storage, |cols| {
1055            let (dense_codes, reps) = cols.group_codes(&cols.dense_to_id, prop)?;
1056            let span = cols
1057                .dense_to_id
1058                .iter()
1059                .copied()
1060                .max()
1061                .map_or(0, |m| m as usize + 1);
1062            let mut codes = vec![crate::columns::ID_GROUP_ABSENT; span];
1063            for (dense, &id) in cols.dense_to_id.iter().enumerate() {
1064                codes[id as usize] = dense_codes[dense];
1065            }
1066            Ok::<_, Error>(crate::columns::IdGroupCodes {
1067                codes,
1068                reps: std::sync::Arc::new(reps),
1069            })
1070        })??;
1071        let arc = std::sync::Arc::new(built);
1072        cache.by_prop.insert(prop.to_string(), arc.clone());
1073        Ok(arc)
1074    }
1075
1076    /// Build the in-memory edge property columns now, if they are not built
1077    /// already: the edge counterpart of [`Graph::materialize_property_columns`],
1078    /// with the same contract. Nothing builds the edge columns as a side effect
1079    /// of a small workload, so this is the deliberate warm-up, and it is the
1080    /// edge columns cache file's save site; a repeat at an unchanged generation
1081    /// rewrites nothing.
1082    pub fn materialize_edge_property_columns(&self) -> Result<(), Error> {
1083        #[cfg(feature = "lmdb")]
1084        {
1085            // Captured under the write lock and before the build, for the
1086            // stamp <= absorbed content invariant explained in
1087            // [`Graph::materialize_property_columns`].
1088            let persisted_gen = {
1089                let _guard = self._write_lock.lock();
1090                let rtxn = self.storage.env.read_txn()?;
1091                crate::storage::ids::commit_gen(&self.storage, &rtxn)?
1092            };
1093            let _quiet = crate::columns::MaterializingColumns::install();
1094            self.edge_columns.with_fresh(&self.storage, |cols| {
1095                let _ = crate::cache_file::save_columns(&self.storage, cols, persisted_gen);
1096            })
1097        }
1098        #[cfg(not(feature = "lmdb"))]
1099        {
1100            let _quiet = crate::columns::MaterializingColumns::install();
1101            self.edge_columns.with_fresh(&self.storage, |_| ())
1102        }
1103    }
1104
1105    // ------------------------------------------------------------------
1106    // Edge property columns
1107    //
1108    // The edge counterparts of the node column readers above, backed by an
1109    // independent columnar cache over the `edges` sub-database. They let the
1110    // query layer gather edge (relationship) properties in bulk through a
1111    // dense-index read instead of an LMDB point lookup plus a msgpack decode
1112    // per access. Semantics mirror the node methods exactly: a missing
1113    // property reads as `Value::Null`; a nonexistent edge is
1114    // [`Error::EdgeNotFound`].
1115    // ------------------------------------------------------------------
1116
1117    /// Read one property of an edge through the in-memory edge property
1118    /// columns. Returns `None` for a nonexistent edge and `Some(Value::Null)`
1119    /// for a missing property.
1120    pub fn edge_prop_json(
1121        &self,
1122        id: EdgeId,
1123        prop: &str,
1124    ) -> Result<Option<serde_json::Value>, Error> {
1125        self.edge_columns.with_fresh(&self.storage, |cols| {
1126            cols.id_to_dense.get(&id).map(|&d| {
1127                cols.cols
1128                    .get(prop)
1129                    .and_then(|c| c.get_json_opt(d as usize))
1130                    .unwrap_or(serde_json::Value::Null)
1131            })
1132        })
1133    }
1134
1135    /// Bulk row-major gather of `props` for each edge id in `ids`.
1136    pub fn edge_props_json_table(
1137        &self,
1138        ids: &[EdgeId],
1139        props: &[&str],
1140    ) -> Result<Vec<Vec<serde_json::Value>>, Error> {
1141        self.edge_columns
1142            .with_fresh(&self.storage, |cols| cols.props_table(ids, props))?
1143    }
1144
1145    /// Gathers one property column for edges, where `out[i]` is `prop` on `ids[i]`.
1146    pub fn edge_prop_json_column(
1147        &self,
1148        ids: &[EdgeId],
1149        prop: &str,
1150    ) -> Result<Vec<serde_json::Value>, Error> {
1151        self.edge_columns
1152            .with_fresh(&self.storage, |cols| cols.prop_column(ids, prop))?
1153    }
1154
1155    /// Group `ids` by the exact value of edge property `prop`: one dense group
1156    /// code per id plus one representative value per code.
1157    pub fn edge_prop_group_codes(
1158        &self,
1159        ids: &[EdgeId],
1160        prop: &str,
1161    ) -> Result<(Vec<u32>, Vec<serde_json::Value>), Error> {
1162        self.edge_columns
1163            .with_fresh(&self.storage, |cols| cols.group_codes(ids, prop))?
1164    }
1165
1166    /// The minimum and maximum non-null value of one node property, from the
1167    /// lazily computed statistics over the in-memory property columns.
1168    /// `None` when the property has no typed column or no non-null values, and
1169    /// also when the columns are not built yet: this reader never builds them,
1170    /// because it is advisory (see [`Graph::estimate_equality_selectivity`]).
1171    pub fn node_prop_min_max(
1172        &self,
1173        prop: &str,
1174    ) -> Result<Option<(serde_json::Value, serde_json::Value)>, Error> {
1175        Ok(self
1176            .prop_columns
1177            .with_existing_mut(&self.storage, |cols| {
1178                cols.prop_stats(prop)
1179                    .map(|s| (s.min.clone(), s.max.clone()))
1180            })?
1181            .flatten())
1182    }
1183
1184    /// Estimated fraction of non-null values of `prop` inside the given
1185    /// bounds (either bound optional), from the property's equi-depth
1186    /// histogram. `None` when no statistics exist for the property or the
1187    /// columns are not built yet; this reader never builds them.
1188    pub fn estimate_range_selectivity(
1189        &self,
1190        prop: &str,
1191        lower: Option<&serde_json::Value>,
1192        upper: Option<&serde_json::Value>,
1193    ) -> Result<Option<f64>, Error> {
1194        Ok(self
1195            .prop_columns
1196            .with_existing_mut(&self.storage, |cols| {
1197                cols.prop_stats(prop)
1198                    .map(|s| s.histogram.estimate_range_selectivity(lower, upper))
1199            })?
1200            .flatten())
1201    }
1202
1203    /// Estimated fraction of non-null values of `prop` equal to `val`: exact
1204    /// for the property's most common values, histogram-estimated otherwise.
1205    ///
1206    /// `None` when no statistics exist for the property, and also when the
1207    /// property columns have not been built yet: the estimate only weights plan
1208    /// choices, so answering is never worth one full node scan on a query that
1209    /// would not otherwise materialize the columns.
1210    pub fn estimate_equality_selectivity(
1211        &self,
1212        prop: &str,
1213        val: &serde_json::Value,
1214    ) -> Result<Option<f64>, Error> {
1215        Ok(self
1216            .prop_columns
1217            .with_existing_mut(&self.storage, |cols| {
1218                cols.prop_stats(prop).map(|s| s.equality_selectivity(val))
1219            })?
1220            .flatten())
1221    }
1222
1223    /// Store an extension value (as `Arc`) keyed by its concrete type.
1224    /// Replaces any existing value of the same type.
1225    pub fn set_extension<T: Any + Send + Sync>(&self, val: Arc<T>) {
1226        self.extensions
1227            .lock()
1228            .insert(StdTypeId::of::<T>(), Box::new(val));
1229    }
1230
1231    /// Retrieve an `Arc` to a previously stored extension value, or `None` if absent.
1232    pub fn get_extension<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
1233        self.extensions
1234            .lock()
1235            .get(&StdTypeId::of::<T>())
1236            .and_then(|b| b.downcast_ref::<Arc<T>>())
1237            .cloned()
1238    }
1239
1240    /// Return the extension of type `T`, initializing it with `init` if absent.
1241    ///
1242    /// `init` runs without the extensions lock held, so it may call back into
1243    /// the graph (for example, to read from storage) without risking a lock
1244    /// ordering problem. If two threads initialize concurrently, both may run
1245    /// `init`, but only the first stored value is kept and every caller observes
1246    /// that same `Arc`. `init` is fallible; on error nothing is stored and the
1247    /// error is propagated.
1248    pub fn get_or_init_extension_with<T, E, F>(&self, init: F) -> Result<Arc<T>, E>
1249    where
1250        T: Any + Send + Sync,
1251        F: FnOnce() -> Result<Arc<T>, E>,
1252    {
1253        if let Some(existing) = self.get_extension::<T>() {
1254            return Ok(existing);
1255        }
1256        let value = init()?;
1257        let mut ext = self.extensions.lock();
1258        // Another thread may have initialized while we built ours; prefer the
1259        // already-stored value so all callers share one instance.
1260        if let Some(existing) = ext
1261            .get(&StdTypeId::of::<T>())
1262            .and_then(|b| b.downcast_ref::<Arc<T>>())
1263        {
1264            return Ok(existing.clone());
1265        }
1266        ext.insert(StdTypeId::of::<T>(), Box::new(value.clone()));
1267        Ok(value)
1268    }
1269
1270    /// Execute a read-only transaction inside a closure.
1271    pub fn view<F, T>(&self, f: F) -> Result<T, Error>
1272    where
1273        F: FnOnce(&ReadTxn) -> Result<T, Error>,
1274    {
1275        let rtxn = self.storage.env.read_txn()?;
1276        let txn = ReadTxn { graph: self, rtxn };
1277        f(&txn)
1278    }
1279
1280    /// Execute a read-write transaction inside a closure.
1281    pub fn update<F, T>(&self, f: F) -> Result<T, Error>
1282    where
1283        F: FnOnce(&mut WriteTxn) -> Result<T, Error>,
1284    {
1285        self.debug_assert_not_in_write_txn();
1286        let _guard = self._write_lock.lock();
1287        let wtxn = self.storage.env.write_txn()?;
1288        let mut txn = WriteTxn {
1289            graph: self,
1290            wtxn,
1291            mutations_count: 0,
1292            delta: crate::csr::GraphDelta::default(),
1293            cache: WriteBatchCache::default(),
1294        };
1295        let _txn_guard = WriteTxnGuard::enter(self.write_txn_env_id());
1296        match f(&mut txn) {
1297            Ok(val) => {
1298                let WriteTxn {
1299                    wtxn,
1300                    mutations_count,
1301                    delta,
1302                    graph: _,
1303                    cache: _,
1304                } = txn;
1305                // Publish before any other bookkeeping, so the window in which
1306                // the caches claim to be current while LMDB already holds this
1307                // write is one atomic increment wide rather than the width of
1308                // the batch. See `CsrCache::advance_write_gen`.
1309                self.commit_and_publish(wtxn, mutations_count)?;
1310                #[cfg(test)]
1311                TestHooks::fire(&self.test_hooks.after_commit_before_column_bookkeeping);
1312                // Column bookkeeping next. The CSR snapshot needs nothing here: the
1313                // generation bump above is what tells a reader its snapshot lags, and
1314                // the refresh rebuilds from storage rather than from a delta.
1315                //
1316                // The columns are still a window: for as long as this bookkeeping
1317                // takes, a reader can see committed data through a column set that
1318                // has not absorbed it. Closing that properly is the read-isolation
1319                // question, not an ordering one. Until then the generation bump,
1320                // which happens first and is a single atomic, is what the snapshot
1321                // gate reads.
1322                if delta.force_full {
1323                    self.prop_columns.record_force_full();
1324                } else {
1325                    self.prop_columns.record_touched_many(&delta.added_nodes);
1326                    self.prop_columns.record_touched_many(&delta.updated_nodes);
1327                }
1328                // Edge columns: an edge removal (or a node deletion that may
1329                // cascade to edges) reshuffles the dense edge mapping, so fall
1330                // back to a full rebuild; otherwise patch the added and
1331                // updated edges in.
1332                if delta.force_full || delta.removed_edge {
1333                    self.edge_columns.record_force_full();
1334                } else {
1335                    self.edge_columns.record_touched_many(&delta.added_edge_ids);
1336                    self.edge_columns.record_touched_many(&delta.updated_edges);
1337                }
1338                if mutations_count > 0 {
1339                    self.maybe_spawn_rebuild_n(mutations_count);
1340                }
1341                Ok(val)
1342            }
1343            Err(err) => {
1344                txn.wtxn.abort();
1345                Err(err)
1346            }
1347        }
1348    }
1349
1350    /// Commit `wtxn` and publish the write to the caches' freshness counters as
1351    /// one step, where `count` is the number of mutations the transaction made.
1352    ///
1353    /// Every mutation that changes adjacency, an edge weight, or a node record
1354    /// commits through here rather than calling `wtxn.commit()` directly. The
1355    /// publish is what makes every freshness gate notice the write, so a method
1356    /// that committed without it would leave the caches permanently claiming to be
1357    /// current rather than briefly.
1358    ///
1359    /// This is convention plus a test, not an enforced invariant: `wtxn.commit()`
1360    /// is still called directly by the index, vector, and FTS writers, none of
1361    /// which touch adjacency or a cached property, so nothing structurally prevents
1362    /// a new mutation method from committing without publishing.
1363    /// `publish_tests::every_committing_mutation_publishes_the_write` enumerates
1364    /// today's methods by hand, so add a new one to it. Ordering inside here is
1365    /// deliberate: see [`crate::csr::CsrCache::advance_write_gen`].
1366    pub(super) fn commit_and_publish(
1367        &self,
1368        mut wtxn: crate::storage::RwTxn<'_>,
1369        count: usize,
1370    ) -> Result<(), Error> {
1371        // The persisted generation advances inside the transaction, so it is
1372        // atomic with the mutations it describes; it is what lets a later
1373        // process decide whether an on-disk derived structure (the CSR
1374        // cache file) still reflects storage, which the in-memory counter below
1375        // cannot, since that one restarts with the process.
1376        if count > 0 {
1377            crate::storage::ids::bump_commit_gen(&self.storage, &mut wtxn)?;
1378        }
1379        wtxn.commit()?;
1380        self.csr_cache.advance_write_gen(count as u64);
1381        Ok(())
1382    }
1383
1384    /// Hold the write lock for the duration of `f`, executing `f` without
1385    /// starting an LMDB transaction. Use this to make a multi-step read-then-write
1386    /// sequence (such as MERGE) atomic with respect to other writers.
1387    pub fn with_write_lock<F, R>(&self, f: F) -> R
1388    where
1389        F: FnOnce() -> R,
1390    {
1391        let _guard = self._write_lock.lock();
1392        f()
1393    }
1394
1395    /// Synchronously rebuild the CSR snapshot from LMDB. Useful after bulk
1396    /// loads or when tests need a consistent read view before the threshold
1397    /// has been crossed.
1398    ///
1399    /// It deliberately does not *ask* for per-edge weights, though it keeps loading
1400    /// them once something else has. This is the call every bulk load makes (`COPY
1401    /// ... FROM` and `IMPORT DATABASE` both end with it), and because the request is
1402    /// sticky, asking here would pin every process that ever loads data to the extra
1403    /// `edges` scan for the rest of its life, whether or not anything asks a
1404    /// weighted question. The one consumer that needs them
1405    /// (`shortest_path_dijkstra`) asks through its own gate on first use.
1406    #[instrument(skip(self))]
1407    pub fn rebuild_csr(&self) -> Result<(), Error> {
1408        // Serialize against every other maintenance path (a foreground refresh and
1409        // the background rebuild) so no two run concurrently.
1410        let _maint = self.csr_cache.maintenance.lock();
1411        // Capture the generation before reading LMDB so writes that land during the
1412        // build leave the snapshot conservatively stale.
1413        let built_gen = self.csr_cache.current_gen();
1414        // The persisted generation, captured before the build for the same
1415        // conservative reason: a write landing mid-build moves the persisted
1416        // counter past the value stamped into the cache file, so the file reads as
1417        // stale rather than claiming a freshness it does not have.
1418        #[cfg(feature = "lmdb")]
1419        let persisted_gen = {
1420            let rtxn = self.storage.env.read_txn()?;
1421            crate::storage::ids::commit_gen(&self.storage, &rtxn)?
1422        };
1423        // Always the full scan, never the cache-file load: this method is the
1424        // file's save site, so serving the file here would write back whatever
1425        // it already claimed and a wrong file could never be repaired.
1426        let snap = self.build_snapshot_from_storage()?;
1427        // This is the one save site, chosen because every bulk load ends here:
1428        // the freshness gate's per-write refreshes must not pay a file write per
1429        // rebuild. A failed save is ignored; the cache file is a cache, and the
1430        // stale or absent file it leaves behind is refused on load.
1431        #[cfg(feature = "lmdb")]
1432        let _ = crate::cache_file::save_csr(
1433            self.storage.env.path(),
1434            &snap,
1435            self.storage.db_id,
1436            persisted_gen,
1437        );
1438        self.csr_cache.install_full(snap, built_gen);
1439        Ok(())
1440    }
1441
1442    /// Create a hot backup of this database to `destination`.
1443    ///
1444    /// `destination` is a **file path** for the backup snapshot (e.g.
1445    /// `/backups/mydb_2026-05-27.mdb`). The file is a complete, portable
1446    /// LMDB snapshot. Concurrent reads and writes are not blocked.
1447    ///
1448    /// To restore, create an empty directory, copy the snapshot file to
1449    /// `<dir>/data.mdb`, then call `Graph::open(<dir>, map_size_gb)`.
1450    pub fn backup(&self, destination: &Path) -> Result<(), Error> {
1451        self.storage.copy_to_file(destination, false)
1452    }
1453
1454    /// Same as `backup` but compacts the database during the copy.
1455    ///
1456    /// The resulting file is smaller than a raw backup but the operation
1457    /// takes longer because it rewrites every live page.
1458    pub fn backup_compact(&self, destination: &Path) -> Result<(), Error> {
1459        self.storage.copy_to_file(destination, true)
1460    }
1461
1462    /// Restore a backup snapshot created by `backup` or `backup_compact` into
1463    /// a new database directory.
1464    ///
1465    /// Creates `dst_dir` if it does not exist, then copies `snapshot_file` into
1466    /// `dst_dir/data.mdb`. After this call succeeds the caller can open the
1467    /// restored database with `Graph::open(dst_dir, map_size_gb)`.
1468    /// Delegates to the storage backend, which is what makes the pair symmetric: a
1469    /// backend that cannot produce a snapshot (`backup`) must not claim to consume
1470    /// one. Leaving the copy here meant the in-memory backend reported a successful
1471    /// restore having restored nothing, while its `backup` correctly refused.
1472    pub fn restore(snapshot_file: &Path, dst_dir: &Path) -> Result<(), Error> {
1473        Storage::restore_from_file(snapshot_file, dst_dir)
1474    }
1475}
1476
1477#[cfg(test)]
1478mod extension_tests {
1479    use std::sync::Arc;
1480
1481    use tempfile::TempDir;
1482
1483    use super::Graph;
1484
1485    fn open_tmp() -> (TempDir, Graph) {
1486        let dir = TempDir::new().unwrap();
1487        let g = Graph::open(dir.path(), 1).unwrap();
1488        (dir, g)
1489    }
1490
1491    /// Extensions are keyed by concrete type: a stored value round-trips, an
1492    /// absent type returns `None`, and a second `set_extension` replaces the
1493    /// previous value of the same type.
1494    #[test]
1495    fn extension_roundtrip_by_type() {
1496        let (_dir, g) = open_tmp();
1497        assert!(g.get_extension::<String>().is_none());
1498
1499        g.set_extension(Arc::new(String::from("cache")));
1500        let got = g.get_extension::<String>().expect("extension must exist");
1501        assert_eq!(*got, "cache");
1502        assert!(g.get_extension::<u64>().is_none(), "distinct type slot");
1503
1504        g.set_extension(Arc::new(String::from("replaced")));
1505        assert_eq!(*g.get_extension::<String>().unwrap(), "replaced");
1506    }
1507
1508    /// `get_or_init_extension_with` runs `init` only when the slot is empty;
1509    /// later callers observe the first stored value.
1510    #[test]
1511    fn get_or_init_extension_initializes_once() {
1512        let (_dir, g) = open_tmp();
1513
1514        let v1 = g
1515            .get_or_init_extension_with::<u64, std::convert::Infallible, _>(|| Ok(Arc::new(7)))
1516            .unwrap();
1517        assert_eq!(*v1, 7);
1518
1519        let v2 = g
1520            .get_or_init_extension_with::<u64, std::convert::Infallible, _>(|| Ok(Arc::new(9)))
1521            .unwrap();
1522        assert_eq!(*v2, 7, "second init must not replace the stored value");
1523    }
1524
1525    /// An `init` failure stores nothing, so a later successful `init` runs.
1526    #[test]
1527    fn get_or_init_extension_propagates_init_error() {
1528        let (_dir, g) = open_tmp();
1529
1530        let err = g
1531            .get_or_init_extension_with::<u64, &str, _>(|| Err("init failed"))
1532            .unwrap_err();
1533        assert_eq!(err, "init failed");
1534        assert!(g.get_extension::<u64>().is_none());
1535
1536        let v = g
1537            .get_or_init_extension_with::<u64, &str, _>(|| Ok(Arc::new(7)))
1538            .unwrap();
1539        assert_eq!(*v, 7);
1540    }
1541}
1542
1543#[cfg(test)]
1544mod encode_tests {
1545    use serde_json::json;
1546
1547    use super::{MAX_INDEXED_STRING_LEN, decode_property_value, encode_property_value};
1548
1549    /// A string up to the indexable bound encodes and round-trips; one byte over
1550    /// the bound is declined so it never overflows the LMDB key size.
1551    #[test]
1552    fn over_long_strings_are_not_indexed() {
1553        let at_limit = json!("a".repeat(MAX_INDEXED_STRING_LEN));
1554        let encoded = encode_property_value(&at_limit).expect("at-limit string indexes");
1555        assert_eq!(decode_property_value(&encoded), Some(at_limit));
1556
1557        let too_long = json!("a".repeat(MAX_INDEXED_STRING_LEN + 1));
1558        assert_eq!(
1559            encode_property_value(&too_long),
1560            None,
1561            "a string over the bound must not be indexed",
1562        );
1563    }
1564
1565    /// Distinct integers beyond 2^53 must encode to distinct keys. Encoding
1566    /// purely through `f64` (the previous behavior) collapsed them, causing
1567    /// index collisions and wrong `nodes_by_property` matches.
1568    #[test]
1569    fn large_integers_do_not_collide() {
1570        let a = encode_property_value(&json!(9_007_199_254_740_992_i64)).unwrap(); // 2^53
1571        let b = encode_property_value(&json!(9_007_199_254_740_993_i64)).unwrap(); // 2^53 + 1
1572        assert_ne!(a, b, "distinct large integers must encode distinctly");
1573    }
1574
1575    /// An integer and the float of the same real value must encode identically
1576    /// so they keep comparing equal in the index (Cypher treats `30 = 30.0`).
1577    #[test]
1578    fn integer_and_equal_float_unify() {
1579        assert_eq!(
1580            encode_property_value(&json!(30)).unwrap(),
1581            encode_property_value(&json!(30.0)).unwrap(),
1582        );
1583        assert_eq!(
1584            encode_property_value(&json!(0)).unwrap(),
1585            encode_property_value(&json!(0.0)).unwrap(),
1586        );
1587    }
1588
1589    /// Every numeric encoding must be the same length: property lookups match by
1590    /// key prefix, so a value whose encoding prefixes another's would alias.
1591    #[test]
1592    fn numeric_encoding_is_fixed_length() {
1593        for v in [
1594            json!(1),
1595            json!(-1),
1596            json!(0),
1597            json!(i64::MAX),
1598            json!(i64::MIN),
1599            json!(3.5),
1600            json!(-2.5e10),
1601        ] {
1602            assert_eq!(encode_property_value(&v).unwrap().len(), 17, "value {v}");
1603        }
1604    }
1605
1606    /// Byte-lexicographic order of encodings must match numeric order, including
1607    /// across the 2^53 boundary where the disambiguator orders the tie.
1608    #[test]
1609    fn numeric_ordering_preserved() {
1610        let ascending: Vec<i64> = vec![
1611            i64::MIN,
1612            -1_000,
1613            -1,
1614            0,
1615            1,
1616            1_000,
1617            1 << 53,
1618            (1 << 53) + 1,
1619            i64::MAX,
1620        ];
1621        let encoded: Vec<Vec<u8>> = ascending
1622            .iter()
1623            .map(|v| encode_property_value(&json!(v)).unwrap())
1624            .collect();
1625        let mut sorted = encoded.clone();
1626        sorted.sort();
1627        assert_eq!(encoded, sorted, "encodings must sort in numeric order");
1628    }
1629
1630    /// Large integers must decode back to the exact integer, not a rounded float.
1631    #[test]
1632    fn decode_round_trips_large_integer() {
1633        for v in [
1634            json!(0),
1635            json!(-1),
1636            json!(9_007_199_254_740_993_i64),
1637            json!(i64::MAX),
1638        ] {
1639            let enc = encode_property_value(&v).unwrap();
1640            assert_eq!(decode_property_value(&enc), Some(v.clone()), "value {v}");
1641        }
1642    }
1643}
1644
1645// Persistence-dependent: these close a database and reopen the same path, or copy it
1646// to a file. The in-memory backend starts empty on every `open` by design (see
1647// `storage::memory`), so their premise does not hold there and the gate states that
1648// rather than letting them fail as though the backend were broken.
1649#[cfg(feature = "lmdb")]
1650#[cfg(test)]
1651mod restore_tests {
1652    use serde_json::json;
1653    use tempfile::TempDir;
1654
1655    use super::Graph;
1656
1657    /// Restoring over an existing database must fail rather than truncate it.
1658    ///
1659    /// The copy is `fs::copy`, which overwrites, so this used to destroy the
1660    /// destination and report success. Every front end reaches this function, so the
1661    /// refusal belongs here rather than in one of them.
1662    #[test]
1663    fn restore_refuses_an_existing_database() {
1664        let src = TempDir::new().unwrap();
1665        let snap_dir = TempDir::new().unwrap();
1666        let dst = TempDir::new().unwrap();
1667        let snap = snap_dir.path().join("a.mdb");
1668
1669        {
1670            let a = Graph::open(src.path(), 1).unwrap();
1671            a.add_node("FromA", &json!({ "n": 1 })).unwrap();
1672            a.backup(&snap).unwrap();
1673        }
1674        {
1675            let b = Graph::open(dst.path(), 1).unwrap();
1676            for i in 0..5 {
1677                b.add_node("FromB", &json!({ "n": i })).unwrap();
1678            }
1679        }
1680
1681        let err = Graph::restore(&snap, dst.path()).unwrap_err();
1682        assert!(
1683            err.to_string().contains("already contains a database"),
1684            "{err}"
1685        );
1686
1687        // The destination is untouched.
1688        let b = Graph::open(dst.path(), 1).unwrap();
1689        assert_eq!(b.nodes_by_label("FromB").unwrap().len(), 5);
1690        assert!(b.nodes_by_label("FromA").unwrap().is_empty());
1691
1692        // A fresh directory still works, including one that does not exist yet.
1693        let fresh = TempDir::new().unwrap();
1694        let nested = fresh.path().join("new");
1695        Graph::restore(&snap, &nested).unwrap();
1696        let restored = Graph::open(&nested, 1).unwrap();
1697        assert_eq!(restored.nodes_by_label("FromA").unwrap().len(), 1);
1698    }
1699
1700    /// Restoring into a directory with leftover cache files removes them: they
1701    /// describe whatever database used to live there, and the database identity
1702    /// they carry means they could never serve the restored one anyway.
1703    #[test]
1704    fn restore_removes_leftover_cache_files() {
1705        let old = TempDir::new().unwrap();
1706        let snap_dir = TempDir::new().unwrap();
1707        let snap = snap_dir.path().join("b.mdb");
1708
1709        {
1710            let a = Graph::open(old.path(), 1).unwrap();
1711            let n0 = a.add_node("N", &json!({ "x": 1 })).unwrap();
1712            let n1 = a.add_node("N", &json!({ "x": 2 })).unwrap();
1713            a.add_edge(n0, n1, "R", &json!({})).unwrap();
1714            a.rebuild_csr().unwrap();
1715            a.materialize_property_columns().unwrap();
1716        }
1717        {
1718            let b_dir = TempDir::new().unwrap();
1719            let b = Graph::open(b_dir.path(), 1).unwrap();
1720            b.add_node("FromB", &json!({})).unwrap();
1721            b.backup(&snap).unwrap();
1722        }
1723        // The old database goes away, its cache files stay behind.
1724        std::fs::remove_file(old.path().join("data.mdb")).unwrap();
1725        let _ = std::fs::remove_file(old.path().join("lock.mdb"));
1726        assert!(old.path().join("csr.cache").exists());
1727
1728        Graph::restore(&snap, old.path()).unwrap();
1729        let leftover: Vec<_> = std::fs::read_dir(old.path())
1730            .unwrap()
1731            .filter_map(|e| e.ok())
1732            .map(|e| e.path())
1733            .filter(|p| p.extension().is_some_and(|ext| ext == "cache"))
1734            .collect();
1735        assert!(leftover.is_empty(), "leftover cache files: {leftover:?}");
1736
1737        let restored = Graph::open(old.path(), 1).unwrap();
1738        assert_eq!(restored.nodes_by_label("FromB").unwrap().len(), 1);
1739    }
1740}
1741
1742// Reopening the same directory is the observable half of the race, so the test
1743// needs the persistent backend.
1744#[cfg(test)]
1745#[cfg(feature = "lmdb")]
1746mod stamp_race_tests {
1747    use std::sync::mpsc;
1748
1749    use serde_json::json;
1750    use tempfile::TempDir;
1751
1752    use super::Graph;
1753
1754    /// The columns cache file must never be stamped ahead of its content.
1755    ///
1756    /// The interleaving under test: a writer commits, and a concurrent
1757    /// materialize reads the bumped persisted generation before the writer
1758    /// records its touched ids into the columns' pending buffer. Capturing the
1759    /// generation without the write lock let the materialize refresh against
1760    /// an empty pending buffer, save pre-write column values, and stamp them
1761    /// with the post-write generation; a reopened graph then loaded the file
1762    /// as fresh and served the pre-write value. The hook parks the writer in
1763    /// exactly that window. Under the fixed code the materialize blocks on the
1764    /// write lock instead, so it absorbs the write before saving.
1765    #[test]
1766    fn a_concurrent_materialize_does_not_stamp_the_cache_file_ahead_of_its_content() {
1767        let dir = TempDir::new().unwrap();
1768        let node;
1769        {
1770            let g = Graph::open(dir.path(), 1).unwrap();
1771            node = g.add_node("Person", &json!({ "v": 1 })).unwrap();
1772            // Build and persist the columns first, so the racing materialize
1773            // refreshes through the pending buffer rather than a full scan,
1774            // which would read the committed value and hide the race.
1775            g.materialize_property_columns().unwrap();
1776
1777            let (reached_tx, reached_rx) = mpsc::channel::<()>();
1778            let (release_tx, release_rx) = mpsc::channel::<()>();
1779            g.test_hooks
1780                .after_commit_before_column_bookkeeping
1781                .lock()
1782                .replace(Box::new(move || {
1783                    reached_tx.send(()).unwrap();
1784                    release_rx.recv().unwrap();
1785                }));
1786
1787            let writer = {
1788                let g = g.clone();
1789                std::thread::spawn(move || {
1790                    g.update(|txn| txn.update_node(node, &json!({ "v": 2 })))
1791                        .unwrap();
1792                })
1793            };
1794            // The writer has committed and is parked before its column
1795            // bookkeeping, still holding the write lock.
1796            reached_rx.recv().unwrap();
1797            let materializer = {
1798                let g = g.clone();
1799                std::thread::spawn(move || g.materialize_property_columns().unwrap())
1800            };
1801            // Ordering help only, not correctness: give the materializer a
1802            // moment to reach the generation capture before the writer is
1803            // released. Under the fixed code it blocks there on the write
1804            // lock; under the racy ordering it completes its save here.
1805            std::thread::sleep(std::time::Duration::from_millis(100));
1806            release_tx.send(()).unwrap();
1807            writer.join().unwrap();
1808            materializer.join().unwrap();
1809        }
1810
1811        let g = Graph::open(dir.path(), 1).unwrap();
1812        // A full build serves from the cache file when its stamp matches the
1813        // persisted generation, which is exactly the load a stamp ahead of
1814        // its content poisons.
1815        g.materialize_property_columns().unwrap();
1816        assert_eq!(
1817            g.node_prop_json(node, "v").unwrap(),
1818            Some(json!(2)),
1819            "the reopened graph must serve the committed value through the loaded columns"
1820        );
1821    }
1822}
1823
1824#[cfg(test)]
1825mod publish_tests {
1826    use serde_json::json;
1827    use tempfile::TempDir;
1828
1829    use super::Graph;
1830
1831    /// Every committing mutation must publish its write to the freshness
1832    /// counters, which is what [`Graph::commit_and_publish`] exists to make
1833    /// unforgettable. A method that committed without publishing would leave
1834    /// every gate reporting the caches as current, so a typed expansion or a
1835    /// graph algorithm would read pre-write state indefinitely rather than for
1836    /// the length of one atomic increment.
1837    #[test]
1838    fn every_committing_mutation_publishes_the_write() {
1839        let dir = TempDir::new().unwrap();
1840        let g = Graph::open(dir.path(), 1).unwrap();
1841        let a = g.add_node("P", &json!({ "n": 1 })).unwrap();
1842        let b = g.add_node("P", &json!({ "n": 2 })).unwrap();
1843        let edge = g.add_edge(a, b, "T", &json!({ "weight": 1.0 })).unwrap();
1844        // Targets for the cases that consume what they touch, created up front so
1845        // the mutation under test is the only write inside its own window.
1846        let victim_node = g.add_node("P", &json!({})).unwrap();
1847        let victim_edge = g.add_edge(a, b, "T", &json!({})).unwrap();
1848        let label_target = g.add_node("P", &json!({})).unwrap();
1849
1850        macro_rules! assert_publishes {
1851            ($name:literal, $body:block) => {{
1852                g.rebuild_csr().unwrap();
1853                assert!(
1854                    !g.csr_cache.snapshot_is_stale(),
1855                    concat!($name, ": a fresh rebuild must report current")
1856                );
1857                $body
1858                assert!(
1859                    g.csr_cache.snapshot_is_stale(),
1860                    concat!($name, " committed without publishing the write generation")
1861                );
1862            }};
1863        }
1864
1865        assert_publishes!("add_node", {
1866            g.add_node("P", &json!({})).unwrap();
1867        });
1868        assert_publishes!("add_node_multi", {
1869            g.add_node_multi(&["P", "Q"], &json!({})).unwrap();
1870        });
1871        assert_publishes!("add_edge", {
1872            g.add_edge(a, b, "T", &json!({})).unwrap();
1873        });
1874        assert_publishes!("update_node", {
1875            g.update_node(a, &json!({ "n": 9 })).unwrap();
1876        });
1877        assert_publishes!("update_edge", {
1878            g.update_edge(edge, &json!({ "weight": 2.0 })).unwrap();
1879        });
1880        assert_publishes!("add_label", {
1881            g.add_label(label_target, "R").unwrap();
1882        });
1883        assert_publishes!("remove_label", {
1884            g.remove_label(label_target, "R").unwrap();
1885        });
1886        assert_publishes!("delete_edge", {
1887            g.delete_edge(victim_edge).unwrap();
1888        });
1889        assert_publishes!("delete_node", {
1890            g.delete_node(victim_node).unwrap();
1891        });
1892        assert_publishes!("update", {
1893            g.update(|txn| {
1894                txn.add_node("P", &json!({}))?;
1895                Ok(())
1896            })
1897            .unwrap();
1898        });
1899    }
1900
1901    /// A `Graph::update` closure that mutates nothing must not advance the
1902    /// generation, so a read-only use of the write transaction does not force
1903    /// every cache to rebuild.
1904    #[test]
1905    fn a_mutation_free_update_publishes_nothing() {
1906        let dir = TempDir::new().unwrap();
1907        let g = Graph::open(dir.path(), 1).unwrap();
1908        g.add_node("P", &json!({})).unwrap();
1909        g.rebuild_csr().unwrap();
1910
1911        g.update(|txn| txn.get_node(1).map(|_| ())).unwrap();
1912
1913        assert!(
1914            !g.csr_cache.snapshot_is_stale(),
1915            "a read-only update must leave the caches current"
1916        );
1917    }
1918}
1919
1920// Persistence-dependent: these close a database and reopen the same path, or copy it
1921// to a file. The in-memory backend starts empty on every `open` by design (see
1922// `storage::memory`), so their premise does not hold there and the gate states that
1923// rather than letting them fail as though the backend were broken.
1924#[cfg(feature = "lmdb")]
1925#[cfg(test)]
1926mod lazy_open_tests {
1927    use serde_json::json;
1928    use tempfile::TempDir;
1929
1930    use super::Graph;
1931    use crate::schema::NodeId;
1932
1933    /// Populate a graph, force the CSR snapshot to build, then close it. Returns
1934    /// the directory so the caller can reopen the same path.
1935    fn seeded_dir() -> (TempDir, Vec<NodeId>) {
1936        let dir = TempDir::new().unwrap();
1937        let ids = {
1938            let g = Graph::open(dir.path(), 1).unwrap();
1939            // 80 nodes in a ring plus a chord, so a typed expansion over more
1940            // than `STALE_POINT_EXPAND_MAX` (64) sources takes the snapshot
1941            // path rather than the per-source LMDB path.
1942            let ids: Vec<_> = (0..80)
1943                .map(|i| g.add_node("Person", &json!({ "n": i })).unwrap())
1944                .collect();
1945            for i in 0..ids.len() {
1946                g.add_edge(ids[i], ids[(i + 1) % ids.len()], "FOLLOWS", &json!({}))
1947                    .unwrap();
1948            }
1949            g.add_edge(ids[0], ids[40], "LIKES", &json!({})).unwrap();
1950            // Touch an algorithm so this handle definitely built the snapshot.
1951            g.bfs(ids[0], 2).unwrap();
1952            assert!(
1953                !g.csr_cache.snapshot_is_stale(),
1954                "seed handle must build the snapshot"
1955            );
1956            ids
1957        };
1958        (dir, ids)
1959    }
1960
1961    /// Opening an existing database does no CSR scan. That is the freshness gate's
1962    /// job, so a workload that only reads properties or point adjacency never pays
1963    /// for it.
1964    #[test]
1965    fn open_defers_the_csr_build() {
1966        let (dir, _ids) = seeded_dir();
1967        let g = Graph::open(dir.path(), 1).unwrap();
1968
1969        assert_eq!(
1970            g.csr_cache.snapshot.load().dense_to_id.len(),
1971            0,
1972            "open must not build the CSR snapshot"
1973        );
1974        assert!(
1975            g.csr_cache.snapshot_is_stale(),
1976            "the unbuilt snapshot must report stale so a consumer rebuilds it"
1977        );
1978    }
1979
1980    /// A freshly opened handle serves every consumer class correctly, each
1981    /// building what it needs through its own gate. This is the guard on the
1982    /// generation bookkeeping: if the unbuilt snapshot reported itself fresh,
1983    /// the typed-expansion path would read an empty CSR and silently return no
1984    /// rows instead of rebuilding.
1985    #[test]
1986    fn reopened_graph_serves_every_consumer_class() {
1987        let (dir, ids) = seeded_dir();
1988
1989        // Each consumer gets its own handle, scoped so the LMDB environment is
1990        // closed before the next open, and so every gate is exercised from the
1991        // unbuilt state rather than riding on an earlier consumer's build.
1992        let reopen = || Graph::open(dir.path(), 1).unwrap();
1993
1994        // Typed expansion over more sources than the stale-point-read cutoff,
1995        // so this goes through `ensure_snapshot_fresh`.
1996        {
1997            let g = reopen();
1998            let wide = g.expand_bulk(&ids, Some("FOLLOWS"), false).unwrap();
1999            assert_eq!(wide.len(), 80, "every ring edge must expand");
2000        }
2001        // Typed expansion under the cutoff, which reads LMDB point adjacency
2002        // directly and needs no snapshot at all.
2003        {
2004            let g = reopen();
2005            let narrow = g.expand_bulk(&ids[..4], Some("FOLLOWS"), false).unwrap();
2006            assert_eq!(narrow.len(), 4);
2007        }
2008        // Matrix-view consumer. Traversal is untyped, so one hop from `ids[0]`
2009        // reaches both the ring successor and the `LIKES` chord target.
2010        {
2011            let g = reopen();
2012            assert_eq!(
2013                g.bfs(ids[0], 1).unwrap().len(),
2014                3,
2015                "start plus both one-hop neighbors"
2016            );
2017        }
2018        // CSR-array consumer.
2019        {
2020            let g = reopen();
2021            assert_eq!(g.dfs(ids[0], 1).unwrap().len(), 3);
2022        }
2023        // Weighted matrix consumer.
2024        {
2025            let g = reopen();
2026            assert_eq!(g.page_rank(5, 0.85).unwrap().len(), 80);
2027        }
2028        {
2029            let g = reopen();
2030            let spec = crate::PathCountSpec {
2031                rel_types: vec![Some("FOLLOWS")],
2032                labels: vec![Some("Person"), Some("Person")],
2033                vertex_allow: Vec::new(),
2034            };
2035            assert_eq!(g.count_linear_paths(&spec).unwrap(), 80);
2036        }
2037        // Point adjacency, which never consults the snapshot.
2038        {
2039            let g = reopen();
2040            assert_eq!(g.out_neighbors(ids[0]).unwrap().len(), 2);
2041        }
2042    }
2043
2044    /// The first gated consumer builds the snapshot, so the deferral is a delay
2045    /// rather than a permanent absence.
2046    #[test]
2047    fn first_algorithm_builds_what_open_skipped() {
2048        let (dir, ids) = seeded_dir();
2049        let g = Graph::open(dir.path(), 1).unwrap();
2050        assert!(g.csr_cache.snapshot_is_stale());
2051
2052        assert_eq!(g.bfs(ids[0], 1).unwrap().len(), 3);
2053
2054        assert!(
2055            !g.csr_cache.snapshot_is_stale(),
2056            "the snapshot gate must build on first use"
2057        );
2058    }
2059
2060    /// Reopening an empty database is also lazy, and every consumer reports
2061    /// empty rather than erroring on the absent snapshot.
2062    #[test]
2063    fn empty_database_opens_lazily_and_reads_empty() {
2064        let dir = TempDir::new().unwrap();
2065        {
2066            Graph::open(dir.path(), 1).unwrap();
2067        }
2068        let g = Graph::open(dir.path(), 1).unwrap();
2069        assert!(g.all_nodes().unwrap().is_empty());
2070        assert!(g.connected_components().unwrap().is_empty());
2071        assert!(g.page_rank(3, 0.85).unwrap().is_empty());
2072    }
2073}