Skip to main content

selene_graph/
shared.rs

1//! Shared graph wrapper implementing lock-free reads and serialized writes.
2
3use std::path::Path;
4use std::sync::{
5    Arc,
6    atomic::{AtomicU64, Ordering},
7};
8
9use arc_swap::ArcSwap;
10use parking_lot::{Mutex, RwLock};
11
12use selene_core::GraphId;
13use selene_persist::{AuditLog, SyncPolicy, WalConfig, WalWriter};
14
15use crate::committer_batch::CommitBatching;
16use crate::core_provider::{CoreProvider, DurableState};
17use crate::durable_provider::DurableProvider;
18use crate::error::{GraphError, GraphResult};
19use crate::graph::SeleneGraph;
20use crate::graph_types::GraphTypeDef;
21use crate::id_allocator::IdAllocator;
22use crate::index_provider::{IndexProvider, ProviderTag};
23use crate::vector_index::{VectorIndexMaintenancePolicy, VectorIndexRebuildReport};
24use crate::write_txn::WriteTxn;
25
26/// Per-graph shared runtime state.
27///
28/// Since v1.2 (BRIEF 1) every snapshot publish is funneled through a single
29/// per-graph committer thread (`CommitterThread`), which is
30/// the **sole writer** of the `snapshot` [`ArcSwap`] cell. `begin_write` hands
31/// each [`WriteTxn`] a cheap submit handle; `commit`/`compact` seal-and-submit
32/// to the committer and block until it publishes. This single-committer +
33/// sole-publisher discipline is what preserves D10 strict-serializability once
34/// `seal()` drops the write lock early — it is load-bearing and NOT
35/// type-enforced (a second committer or ArcSwap writer would silently break it).
36pub struct SharedGraph {
37    shared: Arc<RwLock<Arc<SeleneGraph>>>,
38    snapshot: Arc<ArcSwap<SeleneGraph>>,
39    schema_version: Arc<AtomicU64>,
40    allocator: Arc<Mutex<IdAllocator>>,
41    /// Fixed provider registry, frozen at construction. Shared as one
42    /// allocation so `begin_write` hands the registry to each transaction
43    /// with a single refcount bump instead of a per-transaction `Vec` clone.
44    providers: Arc<[Arc<dyn IndexProvider>]>,
45    durable_providers: Vec<Arc<dyn DurableProvider>>,
46    /// The single per-graph committer thread; sole publisher of `snapshot`.
47    /// Dropped last via [`SharedGraph`]'s implicit drop order, which joins the
48    /// thread once every outstanding [`WriteTxn`] submit handle is gone.
49    committer: crate::committer::CommitterThread,
50}
51
52impl SharedGraph {
53    /// Construct an empty shared graph.
54    #[must_use]
55    pub fn new(graph_id: GraphId) -> Self {
56        Self::from_graph(SeleneGraph::new(graph_id))
57    }
58
59    /// Start building an empty shared graph with optional providers.
60    #[must_use]
61    pub fn builder(graph_id: GraphId) -> SharedGraphBuilder {
62        SharedGraphBuilder::new(graph_id)
63    }
64
65    /// Construct shared state from a pre-built graph snapshot.
66    ///
67    /// The allocator floors are derived from storage length so that stale
68    /// `GraphMeta.next_*_id` values cannot allow ID reuse over rows that
69    /// already exist (recovery hardening — spec 02 §4 forbids ID reuse).
70    ///
71    /// # Panics
72    ///
73    /// Panics if the supplied graph contains more than `u32::MAX` rows in
74    /// either store. Selene-graph's row index is `u32` by construction;
75    /// `SeleneGraph::new()` always satisfies this, and any caller-built
76    /// fixture must too. Use [`SharedGraph::try_from_graph`] for the
77    /// fallible variant when validating untrusted snapshots.
78    #[must_use]
79    pub fn from_graph(graph: SeleneGraph) -> Self {
80        Self::try_from_graph(graph).expect("graph store row count exceeds u32::MAX")
81    }
82
83    /// Fallible variant of [`SharedGraph::from_graph`]. Returns
84    /// [`GraphError::Inconsistent`] when the graph's stores exceed the
85    /// `u32` row capacity.
86    pub fn try_from_graph(graph: SeleneGraph) -> GraphResult<Self> {
87        Self::from_graph_with_core(graph, Vec::new())
88    }
89
90    /// Construct shared state from a graph snapshot and fixed provider list.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`GraphError::Provider`] when two providers declare the same
95    /// [`ProviderTag`], and [`GraphError::Inconsistent`] when the graph's
96    /// stores exceed the `u32` row capacity.
97    pub fn from_graph_with_providers(
98        graph: SeleneGraph,
99        providers: Vec<Arc<dyn IndexProvider>>,
100    ) -> GraphResult<Self> {
101        Self::from_graph_with_core(graph, providers)
102    }
103
104    /// Construct shared state from a graph snapshot and commit-critical WAL file.
105    ///
106    /// Since v1.2 (BRIEF 2) the committer is the sole fsync caller, so the WAL is
107    /// **always** opened in [`SyncPolicy::OnFlushOnly`] regardless of the
108    /// `config.sync_policy` passed (it is overwritten before
109    /// [`WalWriter::open`]). This non-builder constructor uses
110    /// [`CommitBatching::Off`], so the committer still fsyncs once per commit —
111    /// behaviorally identical to BRIEF 1's `EveryN(1)`.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`GraphError::Persist`] when the WAL cannot be opened, plus the
116    /// same consistency and provider-registration errors as [`Self::try_from_graph`].
117    pub fn from_graph_with_wal(
118        graph: SeleneGraph,
119        path: impl AsRef<Path>,
120        mut config: WalConfig,
121    ) -> GraphResult<Self> {
122        // BRIEF 2: the committer owns fsync via flush_durables(); force the
123        // committer-managed WAL into OnFlushOnly before opening it (overwriting
124        // any caller policy), keeping open-error timing unchanged.
125        config.sync_policy = SyncPolicy::OnFlushOnly;
126        let writer = WalWriter::open(path.as_ref(), config)?;
127        Self::from_graph_with_core_and_durables(
128            graph,
129            Vec::new(),
130            Vec::new(),
131            Some(writer),
132            None,
133            CommitBatching::Off,
134        )
135    }
136
137    fn from_graph_with_core(
138        graph: SeleneGraph,
139        providers: Vec<Arc<dyn IndexProvider>>,
140    ) -> GraphResult<Self> {
141        Self::from_graph_with_core_and_durables(
142            graph,
143            providers,
144            Vec::new(),
145            None,
146            None,
147            CommitBatching::Off,
148        )
149    }
150
151    pub(crate) fn from_graph_with_core_and_durables(
152        graph: SeleneGraph,
153        providers: Vec<Arc<dyn IndexProvider>>,
154        mut durable_providers: Vec<Arc<dyn DurableProvider>>,
155        wal_writer: Option<WalWriter>,
156        audit_log: Option<AuditLog>,
157        batching: CommitBatching,
158    ) -> GraphResult<Self> {
159        if audit_log.is_some() && wal_writer.is_none() {
160            return Err(GraphError::Inconsistent {
161                reason: "audit log configured without a WAL; audit mirroring requires durable WAL \
162                         state"
163                    .to_owned(),
164            });
165        }
166        let snapshot = Arc::new(ArcSwap::from_pointee(graph.clone()));
167        let has_wal = wal_writer.is_some();
168        let durable = wal_writer
169            .map(DurableState::new)
170            .map(|durable| match audit_log {
171                Some(audit) => durable.with_audit_log(audit),
172                None => durable,
173            });
174        let core = CoreProvider::new_for_live_with_wal(Arc::clone(&snapshot), durable);
175        let mut all_providers = Vec::with_capacity(providers.len() + 1);
176        all_providers.push(core.clone() as Arc<dyn IndexProvider>);
177        all_providers.extend(providers);
178        if has_wal {
179            durable_providers.push(core as Arc<dyn DurableProvider>);
180        }
181        validate_unique_provider_tags(&all_providers)?;
182        Self::from_graph_parts_and_snapshot(
183            graph,
184            all_providers,
185            durable_providers,
186            snapshot,
187            batching,
188        )
189    }
190
191    pub(crate) fn from_graph_parts_and_snapshot(
192        graph: SeleneGraph,
193        providers: Vec<Arc<dyn IndexProvider>>,
194        durable_providers: Vec<Arc<dyn DurableProvider>>,
195        snapshot: Arc<ArcSwap<SeleneGraph>>,
196        batching: CommitBatching,
197    ) -> GraphResult<Self> {
198        validate_unique_provider_tags(&providers)?;
199        // Freeze the registry into one shared allocation: the committer and
200        // every `begin_write` transaction clone the `Arc`, not the `Vec`.
201        let providers: Arc<[Arc<dyn IndexProvider>]> = providers.into();
202        let mut graph = graph;
203        rebuild_derived_state(&mut graph)?;
204        crate::property_index::rebuild_property_indexes(&mut graph)?;
205        crate::property_index::rebuild_edge_property_indexes(&mut graph)?;
206        crate::composite_property_index::rebuild_composite_property_indexes(&mut graph)?;
207        crate::vector_index::rebuild_vector_indexes(&mut graph)?;
208        crate::text_index::rebuild_text_indexes(&mut graph)?;
209        if let Some(type_def) = graph.meta.bound_type.as_deref() {
210            // Why: GraphMeta is publicly constructible, so SharedGraph::from_graph
211            // can land a malformed bound_type that bypassed builder().bound_to()'s
212            // validate(). Re-check self-consistency here so every constructor
213            // arrives at the same closed-graph admissibility contract.
214            type_def.validate_ref()?;
215            crate::type_validator::validate_entity_state(&graph, type_def)?;
216        }
217
218        let node_floor = (graph.node_store.labels.len() as u64).saturating_add(1);
219        let edge_floor = (graph.edge_store.label.len() as u64).saturating_add(1);
220        let allocator = IdAllocator::from_meta_with_floors(&graph.meta, node_floor, edge_floor);
221
222        // Debug-only structural net on the snapshot-load / recovery path: the
223        // rebuild_* helpers above re-derive all indexes from columns, so a
224        // rebuild bug would otherwise surface only as silent query
225        // corruption. Highest-value placement — verify the rebuilt snapshot
226        // before it is ever published. Compiled out in release builds.
227        #[cfg(debug_assertions)]
228        if let Err(reason) = graph.assert_indexes_consistent() {
229            return Err(GraphError::Inconsistent {
230                reason: format!("rebuilt snapshot failed index consistency check: {reason}"),
231            });
232        }
233
234        let graph = Arc::new(graph);
235        snapshot.store(Arc::clone(&graph));
236        let shared = Arc::new(RwLock::new(graph));
237        let schema_version = Arc::new(AtomicU64::new(0));
238        let allocator = Arc::new(Mutex::new(allocator));
239        // Spawn the single per-graph committer thread. It captures clones of
240        // every handle it needs to publish + compact; it is the sole writer of
241        // `snapshot`. All commit/compact/index-DDL publishes route through it.
242        let committer =
243            crate::committer::CommitterThread::spawn(crate::committer::CommitterHandles {
244                snapshot: Arc::clone(&snapshot),
245                schema_version: Arc::clone(&schema_version),
246                providers: Arc::clone(&providers),
247                durable_providers: durable_providers.clone(),
248                batching,
249            });
250        Ok(Self {
251            shared,
252            snapshot,
253            schema_version,
254            allocator,
255            providers,
256            durable_providers,
257            committer,
258        })
259    }
260
261    /// Load the current immutable snapshot without taking the write lock.
262    #[must_use]
263    pub fn read(&self) -> Arc<SeleneGraph> {
264        self.snapshot.load_full()
265    }
266
267    /// Return compaction pressure for the current published snapshot.
268    ///
269    /// This is a lock-free read of row counts and liveness counters. It does not
270    /// compact, rebuild indexes, or take the writer lock.
271    #[must_use]
272    pub fn compaction_stats(&self) -> crate::compaction::CompactionStats {
273        self.read().compaction_stats()
274    }
275
276    /// Compact the live graph in place: reclaim every dead / hole row, renumber
277    /// rows dense, and atomically republish the result so the RAM held by deleted
278    /// rows is reclaimed immediately (BRIEF-Item-4c — the live-densify half of
279    /// snapshot-time compaction).
280    ///
281    /// This is pure space reclamation: it changes only the internal row layout,
282    /// never external `NodeId`/`EdgeId`, properties, or labels, so it emits **no**
283    /// [`selene_core::Change`] and writes **no** WAL entry. Durability
284    /// comes from the next snapshot, which encodes the now-dense live graph (the
285    /// CORE provider reads the same `snapshot` cell this method publishes into). A
286    /// crash before that snapshot simply reloads the pre-compaction state and
287    /// recompacts later — compaction can never lose data.
288    ///
289    /// The dense graph is built under the write lock on the calling thread
290    /// (seal-and-handover, exactly like a commit), and is allocated a publish
291    /// `seal_seq` under that same lock; the single committer then swaps it into
292    /// the published `snapshot` cell strictly in `seal_seq` order. So compaction
293    /// serializes with writers exactly like a commit and can never be reordered
294    /// ahead of an earlier-sealed commit (which would let that commit's stale,
295    /// non-dense frozen snapshot clobber the dense one). Lock-free readers keep
296    /// observing the old snapshot until the dense graph is published. The
297    /// monotonic allocator high-water marks are preserved (the live allocator is
298    /// untouched, and [`compact_core`](crate::compact_core) carries `GraphMeta`
299    /// verbatim — and the allocator is kept in sync with `GraphMeta` on every
300    /// commit), so no external id is ever reused after a later recovery.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`GraphError`] if the graph's id↔row mapping is corrupt or the
305    /// recompacted graph fails its consistency check (see
306    /// [`compact_core`](crate::compact_core)).
307    pub fn compact(&self) -> GraphResult<crate::CompactionReport> {
308        // Seal-and-handover for compaction (v1.2 BRIEF 1, P1 fix): build the
309        // dense graph HERE, on the caller thread, under the write lock — exactly
310        // like a commit seals under the lock — then hand the committer a
311        // pre-built dense snapshot to publish in seal_seq order. This keeps the
312        // committer off the write lock entirely (no deadlock surface) and, more
313        // importantly, ties compaction's publish position to a seal_seq taken
314        // under the same lock as commits, so a compact can never be reordered
315        // ahead of an earlier-sealed commit (which would otherwise let an
316        // earlier commit's stale, non-dense frozen snapshot clobber the dense
317        // one in the published cell).
318        //
319        // Ordering under the lock is load-bearing for the reorder buffer's
320        // gap-free invariant: densify FIRST (the only fallible step), and only
321        // THEN allocate the seal_seq, so a failed compaction consumes no
322        // sequence number (which would otherwise wedge the committer waiting for
323        // a seq that never arrives).
324        let committer = self.committer.handle();
325        let (seal_seq, dense, report) = {
326            let mut guard = self.shared.write();
327            let compacted = crate::compaction::compact_core(&guard)?;
328            let dense = Arc::new(compacted.graph);
329            // Allocate the publish-order key under the lock, after the fallible
330            // densify, so seal_seq order == lock-acquisition order and no seq is
331            // ever burned by a failed compaction.
332            let seal_seq = committer.next_seal_seq();
333            *guard = Arc::clone(&dense);
334            (seal_seq, dense, compacted.report)
335            // Lock released here, before the (blocking) enqueue + recv — the
336            // committer never needs the write lock, but releasing here also
337            // means a compactor never holds the lock while blocked on the
338            // committer.
339        };
340        committer.submit_compact(seal_seq, dense, report)
341    }
342
343    /// Rebuild every registered vector index from primary node values.
344    ///
345    /// HNSW indexes retain stale deleted entries after vector update/delete so
346    /// in-flight search can still traverse the neighbor graph safely. This
347    /// maintenance path reclaims those stale entries by rebuilding only the
348    /// derived vector-index state; it does not change graph data, emit
349    /// [`selene_core::Change`], write a WAL entry, bump schema epoch, or
350    /// notify providers. The HNSW graph is derived, not durable: snapshots and
351    /// recovery persist only vector-index registrations plus primary values, so
352    /// a reopen rebuilds the index from that authoritative state.
353    ///
354    /// The rebuild is strict on live data: if an indexed row no longer satisfies
355    /// the registered vector dimension/metric invariant, this method returns an
356    /// error instead of silently dropping the row from the index.
357    pub fn rebuild_vector_indexes(&self) -> GraphResult<VectorIndexRebuildReport> {
358        let committer = self.committer.handle();
359        let (seal_seq, rebuilt, report) = {
360            let mut guard = self.shared.write();
361            let mut rebuilt = guard.as_ref().clone();
362            let report = crate::vector_index::rebuild_vector_indexes_strict(&mut rebuilt)?;
363            let rebuilt = Arc::new(rebuilt);
364            let seal_seq = committer.next_seal_seq();
365            *guard = Arc::clone(&rebuilt);
366            (seal_seq, rebuilt, report)
367        };
368        committer.submit_vector_index_rebuild(seal_seq, rebuilt, report)
369    }
370
371    /// Rebuild only vector indexes whose diagnostics recommend maintenance.
372    ///
373    /// This is the bounded maintenance variant for IVF drift: it uses each index's current
374    /// [`ivf_rebuild_recommended`](crate::vector_index::VectorIndexMemoryUsage::ivf_rebuild_recommended)
375    /// value to decide whether to rebuild that derived index. Indexes that do not recommend rebuild
376    /// are left untouched, and a no-op call returns an empty report without publishing a maintenance
377    /// item.
378    ///
379    /// The rebuild is strict on live data for selected indexes, matching
380    /// [`Self::rebuild_vector_indexes`].
381    pub fn rebuild_recommended_vector_indexes(&self) -> GraphResult<VectorIndexRebuildReport> {
382        self.maintain_vector_indexes(VectorIndexMaintenancePolicy::recommended())
383    }
384
385    /// Maintain recommended vector indexes under a caller-supplied policy.
386    ///
387    /// This is the explicit orchestration API for amortized vector-index maintenance. It rebuilds
388    /// only indexes whose diagnostics currently recommend maintenance and applies the policy cap
389    /// after ordering recommended indexes by pending IVF retrain pressure. It remains a
390    /// maintenance-tier operation: reads never trigger it, and a no-op call returns an empty report
391    /// without publishing a derived-state replacement.
392    ///
393    /// The rebuild is strict on live data for selected indexes, matching
394    /// [`Self::rebuild_vector_indexes`].
395    pub fn maintain_vector_indexes(
396        &self,
397        policy: VectorIndexMaintenancePolicy,
398    ) -> GraphResult<VectorIndexRebuildReport> {
399        let committer = self.committer.handle();
400        let (seal_seq, rebuilt, report) = {
401            let mut guard = self.shared.write();
402            let mut rebuilt = guard.as_ref().clone();
403            let report = crate::vector_index::maintain_vector_indexes_strict(&mut rebuilt, policy)?;
404            if report.entries.is_empty() {
405                return Ok(report);
406            }
407            let rebuilt = Arc::new(rebuilt);
408            let seal_seq = committer.next_seal_seq();
409            *guard = Arc::clone(&rebuilt);
410            (seal_seq, rebuilt, report)
411        };
412        committer.submit_vector_index_rebuild(seal_seq, rebuilt, report)
413    }
414
415    /// Return the runtime schema-version epoch used for plan-cache invalidation.
416    ///
417    /// The epoch starts at zero for each [`SharedGraph`] instance and advances
418    /// only after a successful commit whose change set contains
419    /// [`selene_core::Change::SchemaChanged`].
420    #[must_use]
421    pub fn schema_version(&self) -> u64 {
422        self.schema_version.load(Ordering::Acquire)
423    }
424
425    /// Return the bound graph type, if this is a closed graph.
426    #[must_use]
427    pub fn graph_type(&self) -> Option<Arc<GraphTypeDef>> {
428        self.read().meta.bound_type.as_ref().map(Arc::clone)
429    }
430
431    /// Return true when this graph is bound to a closed graph type.
432    #[must_use]
433    pub fn is_closed(&self) -> bool {
434        self.read().meta.bound_type.is_some()
435    }
436
437    /// Look up a registered provider by tag.
438    #[must_use]
439    pub fn index_provider_by_tag(&self, tag: ProviderTag) -> Option<Arc<dyn IndexProvider>> {
440        self.providers
441            .iter()
442            .find_map(|provider| (provider.provider_tag() == tag).then(|| Arc::clone(provider)))
443    }
444
445    /// Borrow the fixed provider registry for executor procedure contexts.
446    #[must_use]
447    pub fn index_providers(&self) -> &[Arc<dyn IndexProvider>] {
448        &self.providers
449    }
450
451    /// Borrow the fixed commit-critical durable provider registry.
452    #[must_use]
453    pub fn durable_providers(&self) -> &[Arc<dyn DurableProvider>] {
454        &self.durable_providers
455    }
456
457    /// Begin a write transaction by acquiring the single graph write lock.
458    ///
459    /// Concurrent writers from other threads queue normally on the write
460    /// lock; the engine does **not** panic legitimate concurrent writes
461    /// during another commit's provider fanout.
462    ///
463    /// Since v1.2 (BRIEF 1) the actual snapshot publish happens on the single
464    /// committer thread, not here: `WriteTxn::commit` seals under this lock,
465    /// releases it, and hands the frozen bundle to the committer. Provider
466    /// fan-out therefore now runs on the **committer thread**, so the
467    /// re-entrancy guard below protects the committer thread (sound with exactly
468    /// one committer — see `reentry.rs` and the v1.2 design §7.7).
469    ///
470    /// # Panics
471    ///
472    /// Panics when called from inside an [`IndexProvider`] callback **on
473    /// the committer thread** as the active fanout. Re-entrant writes from a
474    /// provider callback are unsupported; the committer is publishing, so a
475    /// nested write would recurse indefinitely. The panic is caught by the
476    /// committer's `notify_providers` boundary; provider state may drift, but
477    /// the commit still completes.
478    ///
479    /// Cross-thread re-entry — a provider spawning a worker thread that
480    /// calls `begin_write` and waiting for it — is **documented misuse**
481    /// rather than a detectable footgun (the engine cannot trace causal
482    /// thread ancestry). See the module docs in `reentry.rs` and the
483    /// `IndexProvider` rustdoc for the contract.
484    #[must_use]
485    #[tracing::instrument(name = "selene.graph.begin_write", skip(self))]
486    pub fn begin_write(&self) -> WriteTxn<'_> {
487        if crate::reentry::in_fanout() {
488            panic!(
489                "selene-graph: SharedGraph::begin_write() called from within \
490                 a provider fan-out callback on the committer thread; \
491                 re-entrant writes from a provider callback are not supported. \
492                 The committer's fan-out boundary will catch this panic; \
493                 the commit succeeds, but the offending provider's \
494                 chained mutation does not."
495            );
496        }
497        WriteTxn::new(
498            self.shared.write(),
499            self.committer.handle(),
500            self.allocator.lock(),
501            Arc::clone(&self.providers),
502        )
503    }
504
505    #[cfg(test)]
506    pub(crate) fn locked_arc_ptr_for_test(&self) -> *const SeleneGraph {
507        let guard = self.shared.read();
508        Arc::as_ptr(&*guard)
509    }
510
511    /// Read the generation of the **live RwLock graph** (`*shared`), as opposed
512    /// to the published `ArcSwap` snapshot. Used by divergence tests to assert
513    /// the two never disagree after a failed / cancelled commit (the P0
514    /// WAL-failure + cancel rollback invariants).
515    #[cfg(test)]
516    pub(crate) fn locked_generation_for_test(&self) -> u64 {
517        self.shared.read().meta.generation
518    }
519
520    /// Submit an already-[`seal`](crate::WriteTxn::seal)ed commit straight to the
521    /// committer, blocking until it is durable + visible. Test-only seam for
522    /// exercising the BRIEF-117 cancellation cut-line (which has no production
523    /// producer yet) without re-entering `commit_with_principal`.
524    #[cfg(test)]
525    pub(crate) fn submit_sealed_for_test(
526        &self,
527        sealed: crate::write_txn::SealedCommit,
528    ) -> GraphResult<crate::CommitOutcome> {
529        self.committer.handle().submit_commit(sealed)
530    }
531
532    /// Enqueue a sealed commit and return its reply receiver without waiting.
533    #[cfg(test)]
534    pub(crate) fn submit_sealed_async_for_test(
535        &self,
536        sealed: crate::write_txn::SealedCommit,
537    ) -> GraphResult<std::sync::mpsc::Receiver<GraphResult<crate::CommitOutcome>>> {
538        self.committer.handle().submit_commit_async_for_test(sealed)
539    }
540}
541
542mod builder;
543mod index_ddl;
544mod rebuild;
545pub use builder::SharedGraphBuilder;
546pub(crate) use rebuild::{rebuild_derived_state, validate_unique_provider_tags};
547
548#[cfg(test)]
549mod compaction_tests;
550#[cfg(test)]
551#[path = "shared_property_tests.rs"]
552mod property_tests;
553#[cfg(test)]
554mod tests;