Skip to main content

velesdb_mobile/
graph.rs

1//! Graph bindings for VelesDB Mobile (UniFFI).
2//!
3//! Provides UniFFI bindings for graph operations on iOS and Android.
4
5use std::collections::HashMap;
6use std::sync::Arc;
7
8use parking_lot::RwLock;
9
10/// A graph node for knowledge graph construction.
11#[derive(Debug, Clone, uniffi::Record, serde::Serialize, serde::Deserialize)]
12pub struct MobileGraphNode {
13    /// Unique identifier.
14    pub id: u64,
15    /// Node type/label.
16    pub label: String,
17    /// JSON properties as string.
18    pub properties_json: Option<String>,
19    /// Optional vector embedding.
20    pub vector: Option<Vec<f32>>,
21}
22
23/// A graph edge representing a relationship.
24#[derive(Debug, Clone, uniffi::Record, serde::Serialize, serde::Deserialize)]
25pub struct MobileGraphEdge {
26    /// Unique identifier.
27    pub id: u64,
28    /// Source node ID.
29    pub source: u64,
30    /// Target node ID.
31    pub target: u64,
32    /// Relationship type.
33    pub label: String,
34    /// JSON properties as string.
35    pub properties_json: Option<String>,
36}
37
38/// Traversal result from BFS/DFS.
39///
40/// FFI projection of [`velesdb_core::TraversalResult`]. The mobile field
41/// `node_id` corresponds to core's `target_id` (the node reached); `path`
42/// and `depth` mirror core's fields one-for-one. See the `From` impls below
43/// for the canonical mapping — they make any future core field drift a
44/// compile error rather than silent divergence.
45#[derive(Debug, Clone, uniffi::Record)]
46pub struct TraversalResult {
47    /// Target node ID reached (core: `target_id`).
48    pub node_id: u64,
49    /// Edge IDs along the path from the source to this node (core: `path`).
50    pub path: Vec<u64>,
51    /// Depth from source (number of hops).
52    pub depth: u32,
53}
54
55/// Serializes a core property map to the mobile `properties_json` shape.
56///
57/// Returns `None` for an empty map (no properties) and a JSON object string
58/// otherwise. A serialization failure also yields `None` so the projection is
59/// total (FFI conversions cannot return a `Result`).
60fn properties_to_json(
61    properties: &std::collections::HashMap<String, serde_json::Value>,
62) -> Option<String> {
63    if properties.is_empty() {
64        return None;
65    }
66    serde_json::to_string(properties).ok()
67}
68
69impl From<velesdb_core::GraphNode> for MobileGraphNode {
70    fn from(node: velesdb_core::GraphNode) -> Self {
71        Self {
72            id: node.id(),
73            label: node.label().to_string(),
74            properties_json: properties_to_json(node.properties()),
75            vector: node.vector().cloned(),
76        }
77    }
78}
79
80impl From<velesdb_core::GraphEdge> for MobileGraphEdge {
81    fn from(edge: velesdb_core::GraphEdge) -> Self {
82        Self {
83            id: edge.id(),
84            source: edge.source(),
85            target: edge.target(),
86            label: edge.label().to_string(),
87            properties_json: properties_to_json(edge.properties()),
88        }
89    }
90}
91
92impl From<velesdb_core::TraversalResult> for TraversalResult {
93    fn from(result: velesdb_core::TraversalResult) -> Self {
94        Self {
95            node_id: result.target_id,
96            path: result.path,
97            depth: result.depth,
98        }
99    }
100}
101
102/// In-memory graph store for mobile knowledge graphs.
103///
104/// Nodes and edges are held in RAM for fast in-session traversal and are not
105/// written automatically. Call [`save`](Self::save) to persist a snapshot to
106/// disk and [`load`](Self::load) to restore it across app restarts.
107#[derive(uniffi::Object)]
108pub struct MobileGraphStore {
109    nodes: RwLock<HashMap<u64, MobileGraphNode>>,
110    edges: RwLock<HashMap<u64, MobileGraphEdge>>,
111    outgoing: RwLock<HashMap<u64, Vec<u64>>>,
112    incoming: RwLock<HashMap<u64, Vec<u64>>>,
113}
114
115/// On-disk snapshot of a [`MobileGraphStore`] (JSON). The outgoing/incoming
116/// adjacency is rebuilt from `edges` on load, so it is not stored.
117#[derive(serde::Serialize, serde::Deserialize)]
118struct GraphSnapshot {
119    nodes: Vec<MobileGraphNode>,
120    edges: Vec<MobileGraphEdge>,
121}
122
123#[uniffi::export]
124impl MobileGraphStore {
125    /// Creates a new empty graph store.
126    #[uniffi::constructor]
127    pub fn new() -> Arc<Self> {
128        Arc::new(Self {
129            nodes: RwLock::new(HashMap::new()),
130            edges: RwLock::new(HashMap::new()),
131            outgoing: RwLock::new(HashMap::new()),
132            incoming: RwLock::new(HashMap::new()),
133        })
134    }
135
136    /// Persists the current nodes and edges to `path` as JSON so the graph
137    /// survives an app restart (the store is otherwise in-memory only).
138    ///
139    /// # Lock Order
140    ///
141    /// Acquires and RELEASES each read guard in turn — `edges` first, then
142    /// `nodes` — so no two of this store's locks are ever held at once. The
143    /// earlier form built the snapshot in one struct literal, whose temporary
144    /// guards both lived to the end of the statement, taking `nodes` then
145    /// `edges` — the exact reverse of the `edges → outgoing → incoming → nodes`
146    /// order every mutator uses (`add_edge`, `remove_node`, `clear`). A
147    /// concurrent `save`/`remove_node` from two UniFFI-called threads was a
148    /// textbook ABBA deadlock; holding at most one lock here cannot deadlock
149    /// against any acquisition order.
150    pub fn save(&self, path: String) -> Result<(), crate::VelesError> {
151        let edges: Vec<MobileGraphEdge> = self.edges.read().values().cloned().collect();
152        let nodes: Vec<MobileGraphNode> = self.nodes.read().values().cloned().collect();
153        let snapshot = GraphSnapshot { nodes, edges };
154        let bytes = serde_json::to_vec(&snapshot)
155            .map_err(|e| crate::VelesError::database(format!("Graph serialize failed: {e}")))?;
156        std::fs::write(&path, bytes)
157            .map_err(|e| crate::VelesError::database(format!("Graph save to '{path}' failed: {e}")))
158    }
159
160    /// Loads a graph previously written by [`save`](Self::save) from `path`,
161    /// rebuilding the adjacency from the stored edges.
162    #[uniffi::constructor]
163    pub fn load(path: String) -> Result<Arc<Self>, crate::VelesError> {
164        let bytes = std::fs::read(&path).map_err(|e| {
165            crate::VelesError::database(format!("Graph load from '{path}' failed: {e}"))
166        })?;
167        let snapshot: GraphSnapshot = serde_json::from_slice(&bytes)
168            .map_err(|e| crate::VelesError::database(format!("Graph deserialize failed: {e}")))?;
169        let store = Self::new();
170        for node in snapshot.nodes {
171            store.add_node(node);
172        }
173        for edge in snapshot.edges {
174            store.add_edge(edge)?;
175        }
176        Ok(store)
177    }
178
179    /// Adds a node to the graph.
180    pub fn add_node(&self, node: MobileGraphNode) {
181        let mut nodes = self.nodes.write();
182        nodes.insert(node.id, node);
183    }
184
185    /// Adds an edge to the graph.
186    ///
187    /// # Lock Order
188    ///
189    /// Acquires locks in consistent order: edges → outgoing → incoming
190    /// WITHOUT dropping between operations to ensure atomicity.
191    /// This prevents race conditions with concurrent remove_node() calls.
192    pub fn add_edge(&self, edge: MobileGraphEdge) -> Result<(), crate::VelesError> {
193        // CRITICAL FIX: Acquire all locks BEFORE any mutation
194        // and hold them until the operation is complete.
195        // Lock order: edges → outgoing → incoming (consistent with remove_node)
196        let mut edges = self.edges.write();
197        let mut outgoing = self.outgoing.write();
198        let mut incoming = self.incoming.write();
199
200        if edges.contains_key(&edge.id) {
201            return Err(crate::VelesError::database(format!(
202                "Edge with ID {} already exists",
203                edge.id
204            )));
205        }
206
207        let source = edge.source;
208        let target = edge.target;
209        let id = edge.id;
210
211        // All mutations happen while holding all locks
212        edges.insert(id, edge);
213        outgoing.entry(source).or_default().push(id);
214        incoming.entry(target).or_default().push(id);
215
216        // Locks are released here (all at once) when guards go out of scope
217        Ok(())
218    }
219
220    /// Gets a node by ID.
221    pub fn get_node(&self, id: u64) -> Option<MobileGraphNode> {
222        let nodes = self.nodes.read();
223        nodes.get(&id).cloned()
224    }
225
226    /// Gets an edge by ID.
227    pub fn get_edge(&self, id: u64) -> Option<MobileGraphEdge> {
228        let edges = self.edges.read();
229        edges.get(&id).cloned()
230    }
231
232    /// Returns the number of nodes.
233    pub fn node_count(&self) -> u64 {
234        let nodes = self.nodes.read();
235        nodes.len() as u64
236    }
237
238    /// Returns the number of edges.
239    pub fn edge_count(&self) -> u64 {
240        let edges = self.edges.read();
241        edges.len() as u64
242    }
243
244    /// Gets outgoing edges from a node.
245    ///
246    /// # Lock Order
247    ///
248    /// Acquires locks in consistent order: edges → outgoing
249    /// to prevent ABBA deadlock with write operations.
250    pub fn get_outgoing(&self, node_id: u64) -> Vec<MobileGraphEdge> {
251        self.get_edges_from_index(node_id, &self.outgoing)
252    }
253
254    /// Gets incoming edges to a node.
255    ///
256    /// # Lock Order
257    ///
258    /// Acquires locks in consistent order: edges → incoming
259    /// to prevent ABBA deadlock with write operations.
260    pub fn get_incoming(&self, node_id: u64) -> Vec<MobileGraphEdge> {
261        self.get_edges_from_index(node_id, &self.incoming)
262    }
263
264    /// Gets outgoing edges filtered by label.
265    pub fn get_outgoing_by_label(&self, node_id: u64, label: String) -> Vec<MobileGraphEdge> {
266        self.get_outgoing(node_id)
267            .into_iter()
268            .filter(|e| e.label == label)
269            .collect()
270    }
271
272    /// Gets neighbors reachable from a node (1-hop).
273    pub fn get_neighbors(&self, node_id: u64) -> Vec<u64> {
274        self.get_outgoing(node_id)
275            .into_iter()
276            .map(|e| e.target)
277            .collect()
278    }
279
280    /// Performs BFS traversal from a source node.
281    ///
282    /// # Arguments
283    ///
284    /// * `source_id` - Starting node ID
285    /// * `max_depth` - Maximum traversal depth
286    /// * `limit` - Maximum number of results
287    pub fn bfs_traverse(&self, source_id: u64, max_depth: u32, limit: u32) -> Vec<TraversalResult> {
288        self.bfs_traverse_parallel(vec![source_id], max_depth, limit)
289    }
290
291    /// Performs multi-source BFS traversal with deduplication.
292    ///
293    /// Starts BFS from multiple source nodes simultaneously and deduplicates
294    /// results by target node ID (first-seen wins).
295    ///
296    /// # Arguments
297    ///
298    /// * `source_ids` - Starting node IDs
299    /// * `max_depth` - Maximum traversal depth
300    /// * `limit` - Maximum number of results
301    pub fn bfs_traverse_parallel(
302        &self,
303        source_ids: Vec<u64>,
304        max_depth: u32,
305        limit: u32,
306    ) -> Vec<TraversalResult> {
307        use std::collections::{HashSet, VecDeque};
308
309        let mut results: Vec<TraversalResult> = Vec::new();
310        let mut visited: HashSet<u64> = HashSet::new();
311        let mut queue: VecDeque<(u64, u32, Vec<u64>)> = VecDeque::new();
312
313        for &source_id in &source_ids {
314            if visited.insert(source_id) {
315                queue.push_back((source_id, 0, Vec::new()));
316            }
317        }
318
319        while let Some((node_id, depth, path)) = queue.pop_front() {
320            if results.len() >= limit as usize {
321                break;
322            }
323
324            if depth > 0 {
325                results.push(TraversalResult {
326                    node_id,
327                    path: path.clone(),
328                    depth,
329                });
330            }
331
332            self.enqueue_neighbors(node_id, depth, max_depth, &path, &mut visited, &mut queue);
333        }
334
335        results
336    }
337
338    /// Removes a node and all connected edges.
339    ///
340    /// # Lock Order
341    ///
342    /// Acquires locks in consistent order: edges → outgoing → incoming → nodes
343    /// to prevent deadlock with concurrent add_edge() calls.
344    pub fn remove_node(&self, node_id: u64) {
345        // CRITICAL: Acquire locks in consistent order (edges → outgoing → incoming → nodes)
346        // to prevent deadlock with add_edge() which uses (edges → outgoing → incoming)
347        let mut edges = self.edges.write();
348        let mut outgoing = self.outgoing.write();
349        let mut incoming = self.incoming.write();
350        let mut nodes = self.nodes.write();
351
352        nodes.remove(&node_id);
353
354        let outgoing_ids: Vec<u64> = outgoing.remove(&node_id).unwrap_or_default();
355        for edge_id in outgoing_ids {
356            if let Some(edge) = edges.remove(&edge_id) {
357                if let Some(ids) = incoming.get_mut(&edge.target) {
358                    ids.retain(|&id| id != edge_id);
359                }
360            }
361        }
362
363        let incoming_ids: Vec<u64> = incoming.remove(&node_id).unwrap_or_default();
364        for edge_id in incoming_ids {
365            if let Some(edge) = edges.remove(&edge_id) {
366                if let Some(ids) = outgoing.get_mut(&edge.source) {
367                    ids.retain(|&id| id != edge_id);
368                }
369            }
370        }
371    }
372
373    /// Removes an edge by ID.
374    ///
375    /// # Lock Order
376    ///
377    /// Acquires locks in consistent order: edges → outgoing → incoming
378    /// WITHOUT dropping between operations to ensure atomicity.
379    pub fn remove_edge(&self, edge_id: u64) {
380        // CRITICAL FIX: Acquire all locks BEFORE any mutation
381        let mut edges = self.edges.write();
382        let mut outgoing = self.outgoing.write();
383        let mut incoming = self.incoming.write();
384
385        if let Some(edge) = edges.remove(&edge_id) {
386            if let Some(ids) = outgoing.get_mut(&edge.source) {
387                ids.retain(|&id| id != edge_id);
388            }
389            if let Some(ids) = incoming.get_mut(&edge.target) {
390                ids.retain(|&id| id != edge_id);
391            }
392        }
393        // All locks released here
394    }
395
396    /// Clears all nodes and edges.
397    ///
398    /// # Lock Order
399    ///
400    /// Acquires locks in consistent order: edges → outgoing → incoming → nodes
401    pub fn clear(&self) {
402        // Consistent lock order: edges → outgoing → incoming → nodes
403        let mut edges = self.edges.write();
404        let mut outgoing = self.outgoing.write();
405        let mut incoming = self.incoming.write();
406        let mut nodes = self.nodes.write();
407
408        edges.clear();
409        outgoing.clear();
410        incoming.clear();
411        nodes.clear();
412    }
413
414    /// Performs DFS traversal from a source node.
415    ///
416    /// # Arguments
417    ///
418    /// * `source_id` - Starting node ID
419    /// * `max_depth` - Maximum traversal depth
420    /// * `limit` - Maximum number of results
421    pub fn dfs_traverse(&self, source_id: u64, max_depth: u32, limit: u32) -> Vec<TraversalResult> {
422        use std::collections::HashSet;
423
424        let mut results: Vec<TraversalResult> = Vec::new();
425        let mut visited: HashSet<u64> = HashSet::new();
426        let mut stack: Vec<(u64, u32, Vec<u64>)> = vec![(source_id, 0, Vec::new())];
427
428        while let Some((node_id, depth, path)) = stack.pop() {
429            if results.len() >= limit as usize {
430                break;
431            }
432
433            if visited.contains(&node_id) {
434                continue;
435            }
436            visited.insert(node_id);
437
438            if depth > 0 {
439                results.push(TraversalResult {
440                    node_id,
441                    path: path.clone(),
442                    depth,
443                });
444            }
445
446            if depth < max_depth {
447                let neighbors: Vec<_> = self
448                    .get_outgoing(node_id)
449                    .into_iter()
450                    .filter(|e| !visited.contains(&e.target))
451                    .collect();
452
453                for edge in neighbors.into_iter().rev() {
454                    let mut next_path = path.clone();
455                    next_path.push(edge.id);
456                    stack.push((edge.target, depth + 1, next_path));
457                }
458            }
459        }
460
461        results
462    }
463
464    /// Checks if a node exists.
465    pub fn has_node(&self, id: u64) -> bool {
466        let nodes = self.nodes.read();
467        nodes.contains_key(&id)
468    }
469
470    /// Checks if an edge exists.
471    pub fn has_edge(&self, id: u64) -> bool {
472        let edges = self.edges.read();
473        edges.contains_key(&id)
474    }
475
476    /// Gets the out-degree (number of outgoing edges) of a node.
477    #[allow(clippy::cast_possible_truncation)]
478    pub fn out_degree(&self, node_id: u64) -> u32 {
479        let outgoing = self.outgoing.read();
480        // Safe: graph degree unlikely to exceed u32::MAX (4 billion edges from one node)
481        outgoing.get(&node_id).map_or(0, |v| v.len() as u32)
482    }
483
484    /// Gets the in-degree (number of incoming edges) of a node.
485    #[allow(clippy::cast_possible_truncation)]
486    pub fn in_degree(&self, node_id: u64) -> u32 {
487        let incoming = self.incoming.read();
488        // Safe: graph degree unlikely to exceed u32::MAX (4 billion edges to one node)
489        incoming.get(&node_id).map_or(0, |v| v.len() as u32)
490    }
491
492    /// Gets all nodes with a specific label.
493    pub fn get_nodes_by_label(&self, label: String) -> Vec<MobileGraphNode> {
494        let nodes = self.nodes.read();
495        nodes
496            .values()
497            .filter(|n| n.label == label)
498            .cloned()
499            .collect()
500    }
501
502    /// Gets all edges with a specific label.
503    pub fn get_edges_by_label(&self, label: String) -> Vec<MobileGraphEdge> {
504        let edges = self.edges.read();
505        edges
506            .values()
507            .filter(|e| e.label == label)
508            .cloned()
509            .collect()
510    }
511}
512
513/// Internal helpers (not exposed via UniFFI).
514impl MobileGraphStore {
515    /// Resolves edge IDs from an adjacency index to full edge objects.
516    ///
517    /// # Lock Order
518    ///
519    /// Acquires `edges` read-lock first, then the `index` read-lock, matching
520    /// the write-side lock order (edges -> outgoing -> incoming).
521    fn get_edges_from_index(
522        &self,
523        node_id: u64,
524        index: &RwLock<HashMap<u64, Vec<u64>>>,
525    ) -> Vec<MobileGraphEdge> {
526        let edges = self.edges.read();
527        let idx = index.read();
528        idx.get(&node_id)
529            .map(|ids| ids.iter().filter_map(|id| edges.get(id).cloned()).collect())
530            .unwrap_or_default()
531    }
532
533    /// Enqueues unvisited outgoing neighbors of `node_id` for further traversal.
534    ///
535    /// Each enqueued entry carries the edge-ID path taken to reach the neighbor
536    /// (`path` so far plus the traversed edge), mirroring core's
537    /// `TraversalResult::path`. No-op when `depth` has already reached
538    /// `max_depth`.
539    fn enqueue_neighbors(
540        &self,
541        node_id: u64,
542        depth: u32,
543        max_depth: u32,
544        path: &[u64],
545        visited: &mut std::collections::HashSet<u64>,
546        queue: &mut std::collections::VecDeque<(u64, u32, Vec<u64>)>,
547    ) {
548        if depth >= max_depth {
549            return;
550        }
551        for edge in self.get_outgoing(node_id) {
552            if visited.insert(edge.target) {
553                let mut next_path = path.to_vec();
554                next_path.push(edge.id);
555                queue.push_back((edge.target, depth + 1, next_path));
556            }
557        }
558    }
559}
560
561impl Default for MobileGraphStore {
562    fn default() -> Self {
563        Self {
564            nodes: RwLock::new(HashMap::new()),
565            edges: RwLock::new(HashMap::new()),
566            outgoing: RwLock::new(HashMap::new()),
567            incoming: RwLock::new(HashMap::new()),
568        }
569    }
570}
571
572#[cfg(test)]
573#[path = "graph_tests.rs"]
574mod tests;