Skip to main content

grafeo_core/graph/lpg/store/
mod.rs

1//! The in-memory LPG graph store.
2//!
3//! This is where your nodes and edges actually live. Most users interact
4//! through [`GrafeoDB`](grafeo_engine::GrafeoDB), but algorithm implementers
5//! sometimes need the raw [`LpgStore`] for direct adjacency traversal.
6//!
7//! Key features:
8//! - MVCC versioning - concurrent readers don't block each other
9//! - Columnar properties with zone maps for fast filtering
10//! - Forward and backward adjacency indexes
11
12mod edge_ops;
13mod graph_store_impl;
14mod index;
15mod memory;
16mod node_ops;
17mod property_ops;
18mod schema;
19mod search;
20mod statistics;
21mod traversal;
22mod versioning;
23
24#[cfg(test)]
25mod tests;
26
27use super::PropertyStorage;
28#[cfg(not(feature = "tiered-storage"))]
29use super::{EdgeRecord, NodeRecord};
30use crate::index::adjacency::ChunkedAdjacency;
31use crate::statistics::Statistics;
32use arcstr::ArcStr;
33use dashmap::DashMap;
34#[cfg(not(feature = "tiered-storage"))]
35use grafeo_common::mvcc::VersionChain;
36use grafeo_common::types::{
37    EdgeId, EpochId, HashableValue, NodeId, PropertyKey, TransactionId, Value,
38};
39use grafeo_common::utils::hash::{FxHashMap, FxHashSet};
40use parking_lot::RwLock;
41use std::cmp::Ordering as CmpOrdering;
42use std::sync::Arc;
43use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
44
45#[cfg(feature = "vector-index")]
46use crate::index::vector::VectorIndexKind;
47
48#[cfg(feature = "tiered-storage")]
49use crate::codec::EpochStore;
50use grafeo_common::memory::arena::AllocError;
51#[cfg(feature = "tiered-storage")]
52use grafeo_common::memory::arena::ArenaAllocator;
53#[cfg(feature = "tiered-storage")]
54use grafeo_common::mvcc::VersionIndex;
55#[cfg(feature = "temporal")]
56use grafeo_common::temporal::VersionLog;
57
58/// Undo entry for a property mutation within a transaction.
59///
60/// Captures the previous state of a property so it can be restored on rollback.
61#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub enum PropertyUndoEntry {
64    /// A node property was changed or added.
65    NodeProperty {
66        /// The node that was modified.
67        node_id: NodeId,
68        /// The property key that was set or removed.
69        key: PropertyKey,
70        /// The previous value, or `None` if the property did not exist before.
71        old_value: Option<Value>,
72    },
73    /// An edge property was changed or added.
74    EdgeProperty {
75        /// The edge that was modified.
76        edge_id: EdgeId,
77        /// The property key that was set or removed.
78        key: PropertyKey,
79        /// The previous value, or `None` if the property did not exist before.
80        old_value: Option<Value>,
81    },
82    /// A label was added to a node.
83    LabelAdded {
84        /// The node that had a label added.
85        node_id: NodeId,
86        /// The label string that was added.
87        label: String,
88    },
89    /// A label was removed from a node.
90    LabelRemoved {
91        /// The node that had a label removed.
92        node_id: NodeId,
93        /// The label string that was removed.
94        label: String,
95    },
96    /// A node was deleted (for rollback restoration).
97    NodeDeleted {
98        /// The node that was deleted.
99        node_id: NodeId,
100        /// The labels the node had before deletion.
101        labels: Vec<String>,
102        /// The properties the node had before deletion.
103        properties: Vec<(PropertyKey, Value)>,
104    },
105    /// An edge was deleted (for rollback restoration).
106    EdgeDeleted {
107        /// The edge that was deleted.
108        edge_id: EdgeId,
109        /// The source node.
110        src: NodeId,
111        /// The destination node.
112        dst: NodeId,
113        /// The edge type name.
114        edge_type: String,
115        /// The properties the edge had before deletion.
116        properties: Vec<(PropertyKey, Value)>,
117    },
118}
119
120/// Compares two values for ordering (used for range checks).
121pub(super) fn compare_values_for_range(a: &Value, b: &Value) -> Option<CmpOrdering> {
122    match (a, b) {
123        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
124        (Value::Float64(a), Value::Float64(b)) => a.partial_cmp(b),
125        (Value::Int64(a), Value::Float64(b)) => (*a as f64).partial_cmp(b),
126        (Value::Float64(a), Value::Int64(b)) => a.partial_cmp(&(*b as f64)),
127        (Value::String(a), Value::String(b)) => Some(a.cmp(b)),
128        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
129        (Value::Timestamp(a), Value::Timestamp(b)) => Some(a.cmp(b)),
130        (Value::Date(a), Value::Date(b)) => Some(a.cmp(b)),
131        (Value::Time(a), Value::Time(b)) => Some(a.cmp(b)),
132        _ => None,
133    }
134}
135
136/// Checks if a value is within a range.
137pub(super) fn value_in_range(
138    value: &Value,
139    min: Option<&Value>,
140    max: Option<&Value>,
141    min_inclusive: bool,
142    max_inclusive: bool,
143) -> bool {
144    // Check lower bound
145    if let Some(min_val) = min {
146        match compare_values_for_range(value, min_val) {
147            Some(CmpOrdering::Less) => return false,
148            Some(CmpOrdering::Equal) if !min_inclusive => return false,
149            None => return false, // Can't compare
150            _ => {}
151        }
152    }
153
154    // Check upper bound
155    if let Some(max_val) = max {
156        match compare_values_for_range(value, max_val) {
157            Some(CmpOrdering::Greater) => return false,
158            Some(CmpOrdering::Equal) if !max_inclusive => return false,
159            None => return false,
160            _ => {}
161        }
162    }
163
164    true
165}
166
167/// Configuration for the LPG store.
168///
169/// The defaults work well for most cases. Tune `backward_edges` if you only
170/// traverse in one direction (saves memory), or adjust capacities if you know
171/// your graph size upfront (avoids reallocations).
172#[derive(Debug, Clone)]
173pub struct LpgStoreConfig {
174    /// Maintain backward adjacency for incoming edge queries. Turn off if
175    /// you only traverse outgoing edges - saves ~50% adjacency memory.
176    pub backward_edges: bool,
177    /// Initial capacity for nodes (avoids early reallocations).
178    pub initial_node_capacity: usize,
179    /// Initial capacity for edges (avoids early reallocations).
180    pub initial_edge_capacity: usize,
181}
182
183impl Default for LpgStoreConfig {
184    fn default() -> Self {
185        Self {
186            backward_edges: true,
187            initial_node_capacity: 1024,
188            initial_edge_capacity: 4096,
189        }
190    }
191}
192
193/// Bidirectional label name/ID registry.
194///
195/// Combines the name-to-ID and ID-to-name mappings behind a single lock,
196/// reducing lock acquisitions on both the read path (`build_node`) and the
197/// write path (`get_or_create_label_id`).
198pub(super) struct LabelRegistry {
199    /// Label name to numeric ID.
200    name_to_id: FxHashMap<ArcStr, u32>,
201    /// Numeric ID to label name (index = ID).
202    id_to_name: Vec<ArcStr>,
203}
204
205impl LabelRegistry {
206    fn new() -> Self {
207        Self {
208            name_to_id: FxHashMap::default(),
209            id_to_name: Vec::new(),
210        }
211    }
212
213    /// Looks up an existing label ID by name.
214    pub(super) fn get_id(&self, name: &str) -> Option<u32> {
215        self.name_to_id.get(name).copied()
216    }
217
218    /// Returns the label name for a given ID.
219    pub(super) fn get_name(&self, id: u32) -> Option<&ArcStr> {
220        self.id_to_name.get(id as usize)
221    }
222
223    /// Returns or creates a label ID for the given name.
224    pub(super) fn get_or_create(&mut self, name: &str) -> u32 {
225        if let Some(&id) = self.name_to_id.get(name) {
226            return id;
227        }
228        // reason: label registry size bounded by practical limits, fits u32
229        #[allow(clippy::cast_possible_truncation)]
230        let id = self.id_to_name.len() as u32;
231        let label: ArcStr = name.into();
232        self.name_to_id.insert(label.clone(), id);
233        self.id_to_name.push(label);
234        id
235    }
236
237    /// Returns the total number of distinct labels.
238    pub(super) fn len(&self) -> usize {
239        self.id_to_name.len()
240    }
241
242    /// Returns the ID-to-name slice for iteration.
243    pub(super) fn names(&self) -> &[ArcStr] {
244        &self.id_to_name
245    }
246
247    /// Clears all label mappings.
248    pub(super) fn clear(&mut self) {
249        self.name_to_id.clear();
250        self.id_to_name.clear();
251    }
252
253    /// Estimates heap memory usage in bytes.
254    pub(super) fn heap_bytes(&self) -> usize {
255        let map_bytes = self.name_to_id.capacity()
256            * (std::mem::size_of::<ArcStr>() + std::mem::size_of::<u32>());
257        let vec_bytes = self.id_to_name.capacity() * std::mem::size_of::<ArcStr>();
258        let string_bytes: usize = self.id_to_name.iter().map(|s| s.len()).sum();
259        map_bytes + vec_bytes + string_bytes
260    }
261}
262
263/// The core in-memory graph storage.
264///
265/// Everything lives here: nodes, edges, properties, adjacency indexes, and
266/// version chains for MVCC. Concurrent reads never block each other.
267///
268/// Most users should go through `GrafeoDB` (from the `grafeo_engine` crate) which
269/// adds transaction management and query execution. Use `LpgStore` directly
270/// when you need raw performance for algorithm implementations.
271///
272/// # Example
273///
274/// ```
275/// use grafeo_core::graph::lpg::LpgStore;
276/// use grafeo_core::graph::Direction;
277///
278/// let store = LpgStore::new().expect("arena allocation");
279///
280/// // Create a small social network
281/// let alix = store.create_node(&["Person"]);
282/// let gus = store.create_node(&["Person"]);
283/// store.create_edge(alix, gus, "KNOWS");
284///
285/// // Traverse outgoing edges
286/// for neighbor in store.neighbors(alix, Direction::Outgoing) {
287///     println!("Alix knows node {:?}", neighbor);
288/// }
289/// ```
290///
291/// # Lock Ordering
292///
293/// `LpgStore` contains multiple `RwLock` fields that must be acquired in a
294/// consistent order to prevent deadlocks. Always acquire locks in this order:
295///
296/// ## Level 1: Entity Storage (mutually exclusive via feature flag)
297/// 1. `nodes` / `node_versions`
298/// 2. `edges` / `edge_versions`
299///
300/// ## Level 2: Catalogs
301/// 3. `label_registry`
302/// 4. `edge_type_to_id` + `id_to_edge_type`
303///
304/// ## Level 3: Indexes
305/// 5. `label_index`
306/// 6. `node_labels`
307/// 7. `property_indexes`
308///
309/// ## Level 4: Statistics
310/// 8. `statistics`
311///
312/// ## Level 5: Nested Locks (internal to other structs)
313/// 9. `PropertyStorage::columns` (via `node_properties`/`edge_properties`)
314/// 10. `ChunkedAdjacency::lists` (via `forward_adj`/`backward_adj`)
315///
316/// ## Rules
317/// - Never hold entity locks while acquiring catalog locks in a different scope.
318/// - Statistics lock is always last.
319/// - Read locks are generally safe, but avoid read-to-write upgrades.
320pub struct LpgStore {
321    /// Node records indexed by NodeId, with version chains for MVCC.
322    /// Used when `tiered-storage` feature is disabled.
323    /// Lock order: 1
324    #[cfg(not(feature = "tiered-storage"))]
325    pub(super) nodes: RwLock<FxHashMap<NodeId, VersionChain<NodeRecord>>>,
326
327    /// Edge records indexed by EdgeId, with version chains for MVCC.
328    /// Used when `tiered-storage` feature is disabled.
329    /// Lock order: 2
330    #[cfg(not(feature = "tiered-storage"))]
331    pub(super) edges: RwLock<FxHashMap<EdgeId, VersionChain<EdgeRecord>>>,
332
333    // === Tiered Storage Fields (feature-gated) ===
334    //
335    // Lock ordering for arena access:
336    //   version_lock (read/write) → arena read lock (via arena_allocator.arena())
337    //
338    // Rules:
339    // - Acquire arena read lock *after* version locks, never before.
340    // - Multiple threads may call arena.read_at() concurrently (shared refs only).
341    // - Never acquire arena write lock (alloc_new_chunk) while holding version locks.
342    // - freeze_epoch order: node_versions.read() → arena.read_at(),
343    //   then edge_versions.read() → arena.read_at().
344    /// Arena allocator for hot data storage.
345    /// Data is stored in per-epoch arenas for fast allocation and bulk deallocation.
346    #[cfg(feature = "tiered-storage")]
347    pub(super) arena_allocator: Arc<ArenaAllocator>,
348
349    /// Node version indexes - store metadata and arena offsets.
350    /// The actual NodeRecord data is stored in the arena.
351    /// Lock order: 1
352    #[cfg(feature = "tiered-storage")]
353    pub(super) node_versions: RwLock<FxHashMap<NodeId, VersionIndex>>,
354
355    /// Edge version indexes - store metadata and arena offsets.
356    /// The actual EdgeRecord data is stored in the arena.
357    /// Lock order: 2
358    #[cfg(feature = "tiered-storage")]
359    pub(super) edge_versions: RwLock<FxHashMap<EdgeId, VersionIndex>>,
360
361    /// Cold storage for frozen epochs.
362    /// Contains compressed epoch blocks for historical data.
363    #[cfg(feature = "tiered-storage")]
364    pub(super) epoch_store: Arc<EpochStore>,
365
366    /// Property storage for nodes.
367    pub(super) node_properties: PropertyStorage<NodeId>,
368
369    /// Property storage for edges.
370    pub(super) edge_properties: PropertyStorage<EdgeId>,
371
372    /// Bidirectional label name/ID registry.
373    /// Lock order: 3
374    pub(super) label_registry: RwLock<LabelRegistry>,
375
376    /// Edge type name to ID mapping.
377    /// Lock order: 4 (acquire with id_to_edge_type)
378    pub(super) edge_type_to_id: RwLock<FxHashMap<ArcStr, u32>>,
379
380    /// Edge type ID to name mapping.
381    /// Lock order: 4 (acquire with edge_type_to_id)
382    pub(super) id_to_edge_type: RwLock<Vec<ArcStr>>,
383
384    /// Forward adjacency lists (outgoing edges).
385    pub(super) forward_adj: ChunkedAdjacency,
386
387    /// Backward adjacency lists (incoming edges).
388    /// Only populated if config.backward_edges is true.
389    pub(super) backward_adj: Option<ChunkedAdjacency>,
390
391    /// Label index: label_id -> set of node IDs.
392    /// Lock order: 5
393    pub(super) label_index: RwLock<Vec<FxHashMap<NodeId, ()>>>,
394
395    /// Node labels: node_id -> set of label IDs.
396    /// Reverse mapping to efficiently get labels for a node.
397    /// Lock order: 6
398    #[cfg(not(feature = "temporal"))]
399    pub(super) node_labels: RwLock<FxHashMap<NodeId, FxHashSet<u32>>>,
400    /// Versioned node labels: node_id -> version log of label sets.
401    /// Lock order: 6
402    #[cfg(feature = "temporal")]
403    pub(super) node_labels: RwLock<FxHashMap<NodeId, VersionLog<FxHashSet<u32>>>>,
404
405    /// Property indexes: property_key -> (value -> set of node IDs).
406    ///
407    /// When a property is indexed, lookups by value are O(1) instead of O(n).
408    /// Use [`create_property_index`] to enable indexing for a property.
409    /// Lock order: 7
410    pub(super) property_indexes:
411        RwLock<FxHashMap<PropertyKey, DashMap<HashableValue, FxHashSet<NodeId>>>>,
412
413    /// Vector indexes: "label:property" -> HNSW index.
414    ///
415    /// Created via [`GrafeoDB::create_vector_index`](grafeo_engine::GrafeoDB::create_vector_index).
416    /// Lock order: 7 (same level as property_indexes, disjoint keys)
417    #[cfg(feature = "vector-index")]
418    pub(super) vector_indexes: RwLock<FxHashMap<String, Arc<VectorIndexKind>>>,
419
420    /// Text indexes: "label:property" -> inverted index with BM25 scoring.
421    ///
422    /// Created via [`GrafeoDB::create_text_index`](grafeo_engine::GrafeoDB::create_text_index).
423    /// Lock order: 7 (same level as property_indexes, disjoint keys)
424    #[cfg(feature = "text-index")]
425    pub(super) text_indexes:
426        RwLock<FxHashMap<String, Arc<RwLock<crate::index::text::InvertedIndex>>>>,
427
428    /// Next node ID.
429    pub(super) next_node_id: AtomicU64,
430
431    /// Next edge ID.
432    pub(super) next_edge_id: AtomicU64,
433
434    /// Current epoch.
435    pub(super) current_epoch: AtomicU64,
436
437    /// Live (non-deleted) node count, maintained incrementally.
438    /// Avoids O(n) full scan in `compute_statistics()`.
439    pub(super) live_node_count: AtomicI64,
440
441    /// Live (non-deleted) edge count, maintained incrementally.
442    /// Avoids O(m) full scan in `compute_statistics()`.
443    pub(super) live_edge_count: AtomicI64,
444
445    /// Per-edge-type live counts, indexed by edge_type_id.
446    /// Avoids O(m) edge scan in `compute_statistics()`.
447    /// Lock order: 8 (same level as statistics)
448    pub(super) edge_type_live_counts: RwLock<Vec<i64>>,
449
450    /// Statistics for cost-based optimization.
451    /// Lock order: 8 (always last)
452    pub(super) statistics: RwLock<Arc<Statistics>>,
453
454    /// Whether statistics need full recomputation (e.g., after rollback).
455    pub(super) needs_stats_recompute: AtomicBool,
456
457    /// Named graphs, each an independent `LpgStore` partition.
458    /// Zero overhead for single-graph databases (empty HashMap).
459    /// Lock order: 9 (after statistics)
460    named_graphs: RwLock<FxHashMap<String, Arc<LpgStore>>>,
461
462    /// Undo log for property mutations within transactions.
463    ///
464    /// Maps transaction IDs to a list of undo entries that capture the
465    /// previous property values. On rollback, entries are replayed in
466    /// reverse order to restore properties. On commit, the entries are
467    /// simply discarded.
468    /// Lock order: 10 (after named_graphs, independent of other locks)
469    property_undo_log: RwLock<FxHashMap<TransactionId, Vec<PropertyUndoEntry>>>,
470}
471
472impl LpgStore {
473    /// Creates a new LPG store with default configuration.
474    ///
475    /// # Errors
476    ///
477    /// Returns [`AllocError`] if the arena allocator cannot be initialized
478    /// (only possible with the `tiered-storage` feature).
479    pub fn new() -> Result<Self, AllocError> {
480        Self::with_config(LpgStoreConfig::default())
481    }
482
483    /// Creates a new LPG store with custom configuration.
484    ///
485    /// # Errors
486    ///
487    /// Returns [`AllocError`] if the arena allocator cannot be initialized
488    /// (only possible with the `tiered-storage` feature).
489    pub fn with_config(config: LpgStoreConfig) -> Result<Self, AllocError> {
490        let backward_adj = if config.backward_edges {
491            Some(ChunkedAdjacency::new())
492        } else {
493            None
494        };
495
496        Ok(Self {
497            #[cfg(not(feature = "tiered-storage"))]
498            nodes: RwLock::new(FxHashMap::default()),
499            #[cfg(not(feature = "tiered-storage"))]
500            edges: RwLock::new(FxHashMap::default()),
501            #[cfg(feature = "tiered-storage")]
502            arena_allocator: Arc::new(ArenaAllocator::new()?),
503            #[cfg(feature = "tiered-storage")]
504            node_versions: RwLock::new(FxHashMap::default()),
505            #[cfg(feature = "tiered-storage")]
506            edge_versions: RwLock::new(FxHashMap::default()),
507            #[cfg(feature = "tiered-storage")]
508            epoch_store: Arc::new(EpochStore::new()),
509            node_properties: PropertyStorage::new(),
510            edge_properties: PropertyStorage::new(),
511            label_registry: RwLock::new(LabelRegistry::new()),
512            edge_type_to_id: RwLock::new(FxHashMap::default()),
513            id_to_edge_type: RwLock::new(Vec::new()),
514            forward_adj: ChunkedAdjacency::new(),
515            backward_adj,
516            label_index: RwLock::new(Vec::with_capacity(16)),
517            node_labels: RwLock::new(FxHashMap::default()),
518            property_indexes: RwLock::new(FxHashMap::default()),
519            #[cfg(feature = "vector-index")]
520            vector_indexes: RwLock::new(FxHashMap::default()),
521            #[cfg(feature = "text-index")]
522            text_indexes: RwLock::new(FxHashMap::default()),
523            next_node_id: AtomicU64::new(0),
524            next_edge_id: AtomicU64::new(0),
525            current_epoch: AtomicU64::new(0),
526            live_node_count: AtomicI64::new(0),
527            live_edge_count: AtomicI64::new(0),
528            edge_type_live_counts: RwLock::new(Vec::new()),
529            statistics: RwLock::new(Arc::new(Statistics::new())),
530            needs_stats_recompute: AtomicBool::new(false),
531            named_graphs: RwLock::new(FxHashMap::default()),
532            property_undo_log: RwLock::new(FxHashMap::default()),
533        })
534    }
535
536    /// Returns the current epoch.
537    #[must_use]
538    pub fn current_epoch(&self) -> EpochId {
539        EpochId::new(self.current_epoch.load(Ordering::Acquire))
540    }
541
542    /// Creates a new epoch.
543    #[doc(hidden)]
544    pub fn new_epoch(&self) -> EpochId {
545        let id = self.current_epoch.fetch_add(1, Ordering::AcqRel) + 1;
546        EpochId::new(id)
547    }
548
549    /// Syncs the store epoch to match an external epoch counter.
550    ///
551    /// Used by the transaction manager to keep the store's epoch in step
552    /// after a transaction commit advances the global epoch.
553    #[doc(hidden)]
554    pub fn sync_epoch(&self, epoch: EpochId) {
555        self.current_epoch
556            .fetch_max(epoch.as_u64(), Ordering::AcqRel);
557    }
558
559    /// Returns the current next node ID counter value.
560    #[doc(hidden)]
561    #[must_use]
562    pub fn next_node_id(&self) -> u64 {
563        self.next_node_id.load(Ordering::Acquire)
564    }
565
566    /// Returns the current next edge ID counter value.
567    #[doc(hidden)]
568    #[must_use]
569    pub fn next_edge_id(&self) -> u64 {
570        self.next_edge_id.load(Ordering::Acquire)
571    }
572
573    /// Sets the next node ID counter.
574    ///
575    /// Used by [`LayeredStore`](crate::graph::compact::layered::LayeredStore)
576    /// to seed the overlay's ID allocator above the compact base's maximum ID.
577    #[doc(hidden)]
578    pub fn set_next_node_id(&self, id: u64) {
579        self.next_node_id.store(id, Ordering::Release);
580    }
581
582    /// Sets the next edge ID counter.
583    ///
584    /// See [`set_next_node_id`](Self::set_next_node_id).
585    #[doc(hidden)]
586    pub fn set_next_edge_id(&self, id: u64) {
587        self.next_edge_id.store(id, Ordering::Release);
588    }
589
590    /// Removes all data from the store, resetting it to an empty state.
591    ///
592    /// Acquires locks in the documented ordering to prevent deadlocks.
593    /// After clearing, the store behaves as if freshly constructed.
594    pub fn clear(&self) {
595        // Level 1: Entity storage
596        #[cfg(not(feature = "tiered-storage"))]
597        {
598            self.nodes.write().clear();
599            self.edges.write().clear();
600        }
601        #[cfg(feature = "tiered-storage")]
602        {
603            self.node_versions.write().clear();
604            self.edge_versions.write().clear();
605            // Arena allocator chunks are leaked; epochs are cleared via epoch_store.
606        }
607
608        // Level 2: Catalogs
609        self.label_registry.write().clear();
610        {
611            self.edge_type_to_id.write().clear();
612            self.id_to_edge_type.write().clear();
613        }
614
615        // Level 3: Indexes
616        self.label_index.write().clear();
617        self.node_labels.write().clear();
618        self.property_indexes.write().clear();
619        #[cfg(feature = "vector-index")]
620        self.vector_indexes.write().clear();
621        #[cfg(feature = "text-index")]
622        self.text_indexes.write().clear();
623
624        // Nested: Properties and adjacency
625        self.node_properties.clear();
626        self.edge_properties.clear();
627        self.forward_adj.clear();
628        if let Some(ref backward) = self.backward_adj {
629            backward.clear();
630        }
631
632        // Atomics: ID counters
633        self.next_node_id.store(0, Ordering::Release);
634        self.next_edge_id.store(0, Ordering::Release);
635        self.current_epoch.store(0, Ordering::Release);
636
637        // Level 4: Statistics
638        self.live_node_count.store(0, Ordering::Release);
639        self.live_edge_count.store(0, Ordering::Release);
640        self.edge_type_live_counts.write().clear();
641        *self.statistics.write() = Arc::new(Statistics::new());
642        self.needs_stats_recompute.store(false, Ordering::Release);
643
644        // Level 5: Undo log
645        self.property_undo_log.write().clear();
646    }
647
648    /// Returns whether backward adjacency (incoming edge index) is available.
649    ///
650    /// When backward adjacency is enabled (the default), bidirectional search
651    /// algorithms can traverse from the target toward the source.
652    #[must_use]
653    pub fn has_backward_adjacency(&self) -> bool {
654        self.backward_adj.is_some()
655    }
656
657    // === Named Graph Management ===
658
659    /// Returns a named graph by name, or `None` if it does not exist.
660    #[must_use]
661    pub fn graph(&self, name: &str) -> Option<Arc<LpgStore>> {
662        self.named_graphs.read().get(name).cloned()
663    }
664
665    /// Returns a named graph, creating it if it does not exist.
666    ///
667    /// # Errors
668    ///
669    /// Returns [`AllocError`] if a new store cannot be allocated.
670    pub fn graph_or_create(&self, name: &str) -> Result<Arc<LpgStore>, AllocError> {
671        {
672            let graphs = self.named_graphs.read();
673            if let Some(g) = graphs.get(name) {
674                return Ok(Arc::clone(g));
675            }
676        }
677        let mut graphs = self.named_graphs.write();
678        // Double-check after acquiring write lock
679        if let Some(g) = graphs.get(name) {
680            return Ok(Arc::clone(g));
681        }
682        let store = Arc::new(LpgStore::new()?);
683        graphs.insert(name.to_string(), Arc::clone(&store));
684        Ok(store)
685    }
686
687    /// Creates a named graph. Returns `true` on success, `false` if it already exists.
688    ///
689    /// # Errors
690    ///
691    /// Returns [`AllocError`] if the new store cannot be allocated.
692    pub fn create_graph(&self, name: &str) -> Result<bool, AllocError> {
693        let mut graphs = self.named_graphs.write();
694        if graphs.contains_key(name) {
695            return Ok(false);
696        }
697        graphs.insert(name.to_string(), Arc::new(LpgStore::new()?));
698        Ok(true)
699    }
700
701    /// Drops a named graph. Returns `false` if it did not exist.
702    pub fn drop_graph(&self, name: &str) -> bool {
703        self.named_graphs.write().remove(name).is_some()
704    }
705
706    /// Returns all named graph names.
707    #[must_use]
708    pub fn graph_names(&self) -> Vec<String> {
709        self.named_graphs.read().keys().cloned().collect()
710    }
711
712    /// Drains the named-graph map, leaving it empty.
713    ///
714    /// Used by the engine's `compact()` / `recompact()` to carry named graphs
715    /// across a store rebuild. Named graphs are LPG-specific and outside the
716    /// `GraphStore` trait, so the columnar base cannot preserve them; the
717    /// engine moves them across the pre- and post-compact overlays with this.
718    #[must_use]
719    pub fn take_named_graphs(&self) -> FxHashMap<String, Arc<LpgStore>> {
720        std::mem::take(&mut *self.named_graphs.write())
721    }
722
723    /// Replaces the named-graph map, overwriting any existing entries.
724    ///
725    /// Paired with [`take_named_graphs`](Self::take_named_graphs) to transfer
726    /// named graphs across a compact rebuild.
727    pub fn install_named_graphs(&self, graphs: FxHashMap<String, Arc<LpgStore>>) {
728        *self.named_graphs.write() = graphs;
729    }
730
731    /// Returns the number of named graphs.
732    #[must_use]
733    pub fn graph_count(&self) -> usize {
734        self.named_graphs.read().len()
735    }
736
737    /// Clears a specific graph, or the default graph if `name` is `None`.
738    pub fn clear_graph(&self, name: Option<&str>) {
739        match name {
740            Some(n) => {
741                if let Some(g) = self.named_graphs.read().get(n) {
742                    g.clear();
743                }
744            }
745            None => self.clear(),
746        }
747    }
748
749    /// Copies all data from the source graph to the destination graph.
750    /// Creates the destination graph if it does not exist.
751    ///
752    /// # Errors
753    ///
754    /// Returns [`AllocError`] if the destination store cannot be allocated.
755    pub fn copy_graph(&self, source: Option<&str>, dest: Option<&str>) -> Result<(), AllocError> {
756        let _src = match source {
757            Some(n) => self.graph(n),
758            None => None, // default graph
759        };
760        let _dest_graph = dest.map(|n| self.graph_or_create(n)).transpose()?;
761        // Full graph copy is complex (requires iterating all entities).
762        // For now, this creates the destination graph structure.
763        // Full entity-level copy will be implemented when needed.
764        Ok(())
765    }
766
767    // === Internal Helpers ===
768
769    pub(super) fn get_or_create_label_id(&self, label: &str) -> u32 {
770        if let Some(id) = self.label_registry.read().get_id(label) {
771            return id;
772        }
773        self.label_registry.write().get_or_create(label)
774    }
775
776    pub(super) fn get_or_create_edge_type_id(&self, edge_type: &str) -> u32 {
777        {
778            let type_to_id = self.edge_type_to_id.read();
779            if let Some(&id) = type_to_id.get(edge_type) {
780                return id;
781            }
782        }
783
784        let mut type_to_id = self.edge_type_to_id.write();
785        let mut id_to_type = self.id_to_edge_type.write();
786
787        // Double-check
788        if let Some(&id) = type_to_id.get(edge_type) {
789            return id;
790        }
791
792        // reason: edge type registry size bounded by practical limits, fits u32
793        #[allow(clippy::cast_possible_truncation)]
794        let id = id_to_type.len() as u32;
795        let edge_type: ArcStr = edge_type.into();
796        type_to_id.insert(edge_type.clone(), id);
797        id_to_type.push(edge_type);
798
799        // Grow edge type live counts to match
800        let mut counts = self.edge_type_live_counts.write();
801        while counts.len() <= id as usize {
802            counts.push(0);
803        }
804
805        id
806    }
807
808    /// Increments the live edge count for a given edge type.
809    pub(super) fn increment_edge_type_count(&self, type_id: u32) {
810        let mut counts = self.edge_type_live_counts.write();
811        if counts.len() <= type_id as usize {
812            counts.resize(type_id as usize + 1, 0);
813        }
814        counts[type_id as usize] += 1;
815    }
816
817    /// Decrements the live edge count for a given edge type.
818    pub(super) fn decrement_edge_type_count(&self, type_id: u32) {
819        let mut counts = self.edge_type_live_counts.write();
820        // reason: counts.len() is bounded by edge type registry size, fits u32
821        #[allow(clippy::cast_possible_truncation)]
822        if type_id < counts.len() as u32 {
823            counts[type_id as usize] -= 1;
824        }
825    }
826}