Skip to main content

uqa_graph/index/
path_index.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Reachability indexes: primary memory for memory graphs, physical records
8//! for durable graphs. No persistent index is rebuilt or hydrated on open.
9
10use crate::{Direction, GraphStore, GraphStoreError, GraphStoreResult, PersistentGraphStore};
11use std::collections::{BTreeMap, BTreeSet};
12use std::sync::Arc;
13use uqa_storage::{CatalogFacade, PersistentStorageBackend};
14
15type Pairs = BTreeSet<(u64, u64)>;
16type MemoryPaths = BTreeMap<Vec<String>, Pairs>;
17
18#[derive(Clone)]
19enum PathStorage {
20    Memory(Arc<MemoryPaths>),
21    Persistent(Arc<DurablePathIndex>),
22    ReadView {
23        store: Arc<crate::GraphStoreHandle>,
24        graph: String,
25        sequences: Vec<Vec<String>>,
26    },
27}
28
29struct DurablePathIndex {
30    catalog: Arc<dyn CatalogFacade>,
31    backend: Arc<dyn PersistentStorageBackend>,
32    key: String,
33    graph: String,
34    sequences: Vec<Vec<String>>,
35    definition: String,
36    read_gate: parking_lot::ReentrantMutex<()>,
37}
38
39/// Fixed-label-sequence reachability. Durable clones copy only a handle.
40/// Queries return owned results and can report storage errors.
41#[derive(Clone)]
42pub struct PathIndex {
43    storage: PathStorage,
44}
45
46impl std::fmt::Debug for PathIndex {
47    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        formatter
49            .debug_struct("PathIndex")
50            .field("paths", &self.indexed_paths())
51            .field(
52                "persistent",
53                &matches!(self.storage, PathStorage::Persistent(_)),
54            )
55            .finish()
56    }
57}
58
59impl Default for PathIndex {
60    fn default() -> Self {
61        Self {
62            storage: PathStorage::Memory(Arc::default()),
63        }
64    }
65}
66
67fn json_error(error: &serde_json::Error) -> GraphStoreError {
68    GraphStoreError::CorruptGraph(error.to_string())
69}
70
71impl PathIndex {
72    pub fn build<G: GraphStore>(
73        store: &G,
74        graph: &str,
75        sequences: &[Vec<String>],
76    ) -> GraphStoreResult<Self> {
77        let mut paths = BTreeMap::new();
78        for sequence in sequences {
79            let mut pairs = Pairs::new();
80            visit_pairs(store, graph, sequence, |pair| {
81                pairs.insert(pair);
82                Ok(())
83            })?;
84            paths.insert(sequence.clone(), pairs);
85        }
86        Ok(Self {
87            storage: PathStorage::Memory(Arc::new(paths)),
88        })
89    }
90
91    /// Bind a durable definition without reading any entity or reachability row.
92    pub fn open_persistent(
93        catalog: Arc<dyn CatalogFacade>,
94        backend: Arc<dyn PersistentStorageBackend>,
95        key: &str,
96        graph: &str,
97        sequences: &[Vec<String>],
98    ) -> GraphStoreResult<Self> {
99        Ok(Self {
100            storage: PathStorage::Persistent(Arc::new(DurablePathIndex {
101                catalog,
102                backend,
103                key: key.to_owned(),
104                graph: graph.to_owned(),
105                sequences: sequences.to_vec(),
106                definition: serde_json::to_string(sequences).map_err(|error| json_error(&error))?,
107                read_gate: parking_lot::ReentrantMutex::new(()),
108            })),
109        })
110    }
111
112    /// Build in bounded batches in one storage checkpoint. A failure preserves
113    /// both the old definition and its materialization.
114    pub fn build_persistent(
115        catalog: Arc<dyn CatalogFacade>,
116        backend: Arc<dyn PersistentStorageBackend>,
117        key: &str,
118        graph: &str,
119        sequences: &[Vec<String>],
120    ) -> GraphStoreResult<Self> {
121        let definition = serde_json::to_string(sequences).map_err(|error| json_error(&error))?;
122        let mut store =
123            PersistentGraphStore::from_catalog(Arc::clone(&catalog), Arc::clone(&backend));
124        store.transaction(|store| {
125            catalog.save_path_index(key, &definition)?;
126            catalog.clear_path_index_data(key)?;
127            for sequence in sequences {
128                let sequence_key =
129                    serde_json::to_string(sequence).map_err(|error| json_error(&error))?;
130                let mut page = Vec::with_capacity(256);
131                visit_pairs(store, graph, sequence, |pair| {
132                    page.push(pair);
133                    if page.len() == 256 {
134                        catalog.save_path_index_pairs(key, &sequence_key, &page)?;
135                        page.clear();
136                    }
137                    Ok(())
138                })?;
139                if !page.is_empty() {
140                    catalog.save_path_index_pairs(key, &sequence_key, &page)?;
141                }
142            }
143            catalog.finish_path_index_data(key, graph, &definition)?;
144            Ok(())
145        })?;
146        Self::open_persistent(catalog, backend, key, graph, sequences)
147    }
148
149    /// Rebind only the physical session, never copy the index's data.
150    pub fn rebind_persistent(
151        &self,
152        catalog: Arc<dyn CatalogFacade>,
153        backend: Arc<dyn PersistentStorageBackend>,
154    ) -> GraphStoreResult<Self> {
155        match &self.storage {
156            PathStorage::Memory(_) | PathStorage::ReadView { .. } => Ok(self.clone()),
157            PathStorage::Persistent(index) => {
158                Self::open_persistent(catalog, backend, &index.key, &index.graph, &index.sequences)
159            }
160        }
161    }
162
163    /// Bind a transaction/cursor view whose own-write overlay cannot use
164    /// reachability pages from the live storage transaction. Only requested
165    /// sequences are evaluated; no resident index or graph is constructed.
166    #[must_use]
167    pub fn with_graph_read_view(&self, store: Arc<crate::GraphStoreHandle>) -> Self {
168        let (graph, sequences) = match &self.storage {
169            PathStorage::Memory(_) => return self.clone(),
170            PathStorage::Persistent(index) => (&index.graph, &index.sequences),
171            PathStorage::ReadView {
172                graph, sequences, ..
173            } => (graph, sequences),
174        };
175        Self {
176            storage: PathStorage::ReadView {
177                store,
178                graph: graph.clone(),
179                sequences: sequences.clone(),
180            },
181        }
182    }
183
184    pub fn lookup(&self, sequence: &[String]) -> GraphStoreResult<Option<Pairs>> {
185        match &self.storage {
186            PathStorage::Memory(paths) => Ok(paths.get(sequence).cloned()),
187            PathStorage::ReadView {
188                store,
189                graph,
190                sequences,
191            } => {
192                if !sequences.iter().any(|candidate| candidate == sequence) {
193                    return Ok(None);
194                }
195                let mut pairs = Pairs::new();
196                visit_pairs(store.as_ref(), graph, sequence, |pair| {
197                    pairs.insert(pair);
198                    Ok(())
199                })?;
200                Ok(Some(pairs))
201            }
202            PathStorage::Persistent(index) => {
203                if !index
204                    .sequences
205                    .iter()
206                    .any(|candidate| candidate == sequence)
207                {
208                    return Ok(None);
209                }
210                index.lookup(sequence).map(Some)
211            }
212        }
213    }
214
215    pub fn has_path(&self, sequence: &[String]) -> bool {
216        match &self.storage {
217            PathStorage::Memory(paths) => paths.contains_key(sequence),
218            PathStorage::ReadView { sequences, .. } => {
219                sequences.iter().any(|candidate| candidate == sequence)
220            }
221            PathStorage::Persistent(index) => index
222                .sequences
223                .iter()
224                .any(|candidate| candidate == sequence),
225        }
226    }
227
228    pub fn indexed_paths(&self) -> Vec<String> {
229        let paths: BTreeSet<String> = match &self.storage {
230            PathStorage::Memory(paths) => paths.keys().map(|sequence| sequence.join("/")).collect(),
231            PathStorage::ReadView { sequences, .. } => sequences
232                .iter()
233                .map(|sequence| sequence.join("/"))
234                .collect(),
235            PathStorage::Persistent(index) => index
236                .sequences
237                .iter()
238                .map(|sequence| sequence.join("/"))
239                .collect(),
240        };
241        paths.into_iter().collect()
242    }
243}
244
245impl DurablePathIndex {
246    fn lookup(&self, sequence: &[String]) -> GraphStoreResult<Pairs> {
247        struct ReadCheckpoint(Option<Arc<dyn PersistentStorageBackend>>);
248        impl Drop for ReadCheckpoint {
249            fn drop(&mut self) {
250                if let Some(backend) = &self.0 {
251                    let _ = backend.rollback_transaction();
252                }
253            }
254        }
255        let _read = self.read_gate.lock();
256        if self.backend.in_transaction() {
257            return self.lookup_in_snapshot(
258                Arc::clone(&self.catalog),
259                Arc::clone(&self.backend),
260                sequence,
261            );
262        }
263        // An escaped index handle must not borrow transaction ownership from
264        // another concurrent direct query on its original engine session.
265        let session = if self.backend.supports_concurrent_pinned_read_and_write() {
266            self.backend.open_session()?
267        } else {
268            // Single-session providers are supported too; their caller owns
269            // session serialization, as with direct GraphStore queries.
270            uqa_storage::PersistentStorageSession::new(
271                Arc::clone(&self.catalog),
272                Arc::clone(&self.backend),
273            )
274        };
275        let catalog = session.catalog;
276        let backend = session.backend;
277        backend.begin_read_transaction()?;
278        let mut checkpoint = ReadCheckpoint(Some(Arc::clone(&backend)));
279        let result = self.lookup_in_snapshot(catalog, Arc::clone(&backend), sequence);
280        backend.rollback_transaction()?;
281        checkpoint.0 = None;
282        result
283    }
284
285    fn lookup_in_snapshot(
286        &self,
287        catalog: Arc<dyn CatalogFacade>,
288        backend: Arc<dyn PersistentStorageBackend>,
289        sequence: &[String],
290    ) -> GraphStoreResult<Pairs> {
291        let definition = catalog
292            .load_path_indexes()?
293            .into_iter()
294            .find_map(|(key, json)| (key == self.key).then_some(json));
295        if definition.as_deref() != Some(self.definition.as_str()) {
296            return Err(GraphStoreError::InvalidQuery(format!(
297                "path index {:?} was dropped or redefined",
298                self.key
299            )));
300        }
301        let mut result = Pairs::new();
302        if catalog.path_index_data_is_current(&self.key, &self.definition)? {
303            let sequence_key =
304                serde_json::to_string(sequence).map_err(|error| json_error(&error))?;
305            let mut after = None;
306            loop {
307                let pairs = catalog.path_index_pairs(&self.key, &sequence_key, after, 256)?;
308                if pairs.is_empty() {
309                    break;
310                }
311                after = pairs.last().copied();
312                result.extend(pairs);
313            }
314        } else {
315            // A legacy or invalidated index is not a valid access path.
316            // Evaluate only this requested sequence in the current read
317            // snapshot; do not rebuild/retain a whole index or write on read.
318            let store = PersistentGraphStore::from_catalog(catalog, backend);
319            visit_pairs(&store, &self.graph, sequence, |pair| {
320                result.insert(pair);
321                Ok(())
322            })?;
323        }
324        Ok(result)
325    }
326}
327
328fn visit_pairs<G: GraphStore>(
329    store: &G,
330    graph: &str,
331    sequence: &[String],
332    mut visit: impl FnMut((u64, u64)) -> GraphStoreResult<()>,
333) -> GraphStoreResult<()> {
334    let mut after = None;
335    loop {
336        let ids = store.vertex_id_page(graph, after, 256)?;
337        if ids.is_empty() {
338            break;
339        }
340        after = ids.last().copied();
341        for start in ids {
342            let mut frontier = BTreeSet::from([start]);
343            for label in sequence {
344                let mut next = BTreeSet::new();
345                for vertex in frontier {
346                    next.extend(store.neighbors(vertex, Some(label), Direction::Out, graph)?);
347                }
348                frontier = next;
349                if frontier.is_empty() {
350                    break;
351                }
352            }
353            for end in frontier {
354                visit((start, end))?;
355            }
356        }
357    }
358    Ok(())
359}