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}