sqry_core/graph/unified/txn.rs
1//! Graph write transactions for plugin safety.
2//!
3//! This module provides RAII transaction semantics for plugins writing to the
4//! unified graph. Changes are buffered and applied atomically on commit.
5
6use std::path::Path;
7
8use anyhow::{Context, Result};
9
10use crate::graph::node::Language;
11use crate::graph::unified::{CodeGraph, EdgeKind, NodeKind};
12
13use super::edge::EdgeId;
14use super::node::NodeId;
15
16/// RAII transaction wrapper for graph writes.
17///
18/// `GraphWriteTxn` provides a safe interface for plugins to add nodes and edges
19/// to the unified graph. Changes are buffered and applied atomically when
20/// `commit()` is called.
21///
22/// # Design
23///
24/// - **RAII semantics**: Transaction must be explicitly committed or changes are lost
25/// - **Buffered writes**: Changes accumulate in memory until commit
26/// - **Atomic application**: All changes applied together or none at all
27/// - **Error recovery**: Failed commits leave graph unchanged
28///
29/// # Example
30///
31/// ```ignore
32/// use sqry_core::graph::unified::txn::GraphWriteTxn;
33/// use sqry_core::graph::unified::CodeGraph;
34///
35/// let mut graph = CodeGraph::new();
36/// let mut txn = GraphWriteTxn::new(&mut graph);
37///
38/// // Add nodes
39/// let node_id = txn.add_node(
40/// Language::Rust,
41/// "main.rs",
42/// "main",
43/// NodeKind::Function,
44/// Some("fn main() { ... }"),
45/// )?;
46///
47/// // Add edges
48/// txn.add_edge(caller_id, callee_id, EdgeKind::Call)?;
49///
50/// // Commit changes
51/// txn.commit()?;
52/// ```
53pub struct GraphWriteTxn<'a> {
54 /// Mutable reference to the graph being modified.
55 graph: &'a mut CodeGraph,
56
57 /// Buffered node additions (language, file, symbol, kind, signature).
58 pending_nodes: Vec<(Language, String, String, NodeKind, Option<String>)>,
59
60 /// Buffered edge additions (source, target, kind).
61 pending_edges: Vec<(NodeId, NodeId, EdgeKind)>,
62
63 /// Whether this transaction has been committed.
64 committed: bool,
65}
66
67impl<'a> GraphWriteTxn<'a> {
68 /// Creates a new write transaction.
69 ///
70 /// The transaction holds a mutable reference to the graph for its lifetime.
71 /// Changes are not visible until `commit()` is called.
72 ///
73 /// # Example
74 ///
75 /// ```ignore
76 /// let mut graph = CodeGraph::new();
77 /// let mut txn = GraphWriteTxn::new(&mut graph);
78 /// ```
79 #[must_use]
80 pub fn new(graph: &'a mut CodeGraph) -> Self {
81 Self {
82 graph,
83 pending_nodes: Vec::new(),
84 pending_edges: Vec::new(),
85 committed: false,
86 }
87 }
88
89 /// Adds a node to the transaction.
90 ///
91 /// The node is not added to the graph until `commit()` is called.
92 ///
93 /// # Arguments
94 ///
95 /// * `language` - Programming language of the node
96 /// * `file` - Source file path
97 /// * `symbol` - Node name (e.g., "main", "`MyClass::method`")
98 /// * `kind` - Node kind (Function, Class, etc.)
99 /// * `signature` - Optional signature string
100 ///
101 /// # Returns
102 ///
103 /// Temporary `NodeId` that will be valid after commit. This ID is a
104 /// placeholder and should not be used until after `commit()` succeeds.
105 ///
106 /// # Errors
107 ///
108 /// Returns `GraphResult` error if node creation fails (e.g., invalid parameters).
109 ///
110 /// # Panics
111 ///
112 /// Panics if the pending node count exceeds `u32::MAX`.
113 ///
114 /// # Example
115 ///
116 /// ```ignore
117 /// let node_id = txn.add_node(
118 /// Language::Rust,
119 /// "src/main.rs",
120 /// "main",
121 /// NodeKind::Function,
122 /// Some("fn main()"),
123 /// )?;
124 /// ```
125 pub fn add_node(
126 &mut self,
127 language: Language,
128 file: impl Into<String>,
129 symbol: impl Into<String>,
130 kind: NodeKind,
131 signature: Option<String>,
132 ) -> Result<NodeId> {
133 // Generate a temporary node ID (index is pending_nodes.len())
134 let node_index =
135 u32::try_from(self.pending_nodes.len()).expect("pending node index exceeds u32::MAX");
136 let temp_id = NodeId::new(node_index, 0);
137
138 // Buffer the node addition
139 self.pending_nodes
140 .push((language, file.into(), symbol.into(), kind, signature));
141
142 Ok(temp_id)
143 }
144
145 /// Adds an edge to the transaction.
146 ///
147 /// The edge is not added to the graph until `commit()` is called.
148 ///
149 /// # Arguments
150 ///
151 /// * `source` - Source node ID (must exist after commit)
152 /// * `target` - Target node ID (must exist after commit)
153 /// * `kind` - Edge kind (Call, Import, etc.)
154 ///
155 /// # Returns
156 ///
157 /// Temporary `EdgeId` that will be valid after commit.
158 ///
159 /// # Errors
160 ///
161 /// Returns `GraphResult` error if edge creation fails or nodes don't exist.
162 ///
163 /// # Panics
164 ///
165 /// Panics if the pending edge count exceeds `u32::MAX`.
166 ///
167 /// # Example
168 ///
169 /// ```ignore
170 /// txn.add_edge(caller_id, callee_id, EdgeKind::Call)?;
171 /// ```
172 pub fn add_edge(&mut self, source: NodeId, target: NodeId, kind: EdgeKind) -> Result<EdgeId> {
173 // Generate a temporary edge ID
174 let edge_index =
175 u32::try_from(self.pending_edges.len()).expect("pending edge index exceeds u32::MAX");
176 let temp_id = EdgeId::new(edge_index);
177
178 // Buffer the edge addition
179 self.pending_edges.push((source, target, kind));
180
181 Ok(temp_id)
182 }
183
184 /// Commits all buffered changes to the graph.
185 ///
186 /// This method applies all pending nodes and edges atomically. If any
187 /// operation fails, the entire transaction is rolled back and the graph
188 /// remains unchanged.
189 ///
190 /// # Returns
191 ///
192 /// `Ok(())` if all changes were successfully applied.
193 ///
194 /// # Errors
195 ///
196 /// Returns `GraphResult` error if any node or edge addition fails.
197 /// On error, all changes are rolled back.
198 ///
199 /// # Panics
200 ///
201 /// Panics if called twice on the same transaction.
202 ///
203 /// # Example
204 ///
205 /// ```ignore
206 /// txn.commit()?;
207 /// ```
208 pub fn commit(mut self) -> Result<()> {
209 assert!(!self.committed, "Transaction already committed");
210 self.committed = true;
211
212 // Map from temporary IDs to real IDs
213 let mut node_id_map = Vec::new();
214
215 // Apply all node additions
216 for (_language, file, symbol, kind, signature) in &self.pending_nodes {
217 use crate::graph::unified::storage::arena::NodeEntry;
218
219 // Intern/register strings and files
220 let file_id = self
221 .graph
222 .files_mut()
223 .register(Path::new(file))
224 .with_context(|| format!("Failed to register file: {file}"))?;
225 let name_id = self.graph.strings_mut().intern(symbol)?;
226
227 // Create node entry with minimal required fields
228 let mut entry = NodeEntry::new(*kind, name_id, file_id);
229
230 // Add signature if provided
231 if let Some(sig) = signature {
232 let signature_id = self.graph.strings_mut().intern(sig)?;
233 entry = entry.with_signature(signature_id);
234 }
235
236 // Allocate node in arena
237 let node_id = self
238 .graph
239 .nodes_mut()
240 .alloc(entry)
241 .with_context(|| format!("Failed to allocate node for symbol: {symbol}"))?;
242
243 node_id_map.push(node_id);
244 }
245
246 // Apply all edge additions
247 for (source_temp, target_temp, kind) in &self.pending_edges {
248 // Map temporary IDs to real IDs
249 let source = *node_id_map
250 .get(source_temp.index() as usize)
251 .ok_or_else(|| {
252 anyhow::anyhow!(
253 "Source node {} not found in transaction",
254 source_temp.index()
255 )
256 })?;
257
258 let target = *node_id_map
259 .get(target_temp.index() as usize)
260 .ok_or_else(|| {
261 anyhow::anyhow!(
262 "Target node {} not found in transaction",
263 target_temp.index()
264 )
265 })?;
266
267 // Get the file ID from the source node for edge attribution
268 let file_id = if let Some(entry) = self.graph.nodes().get(source) {
269 entry.file
270 } else {
271 // Fallback: register empty path
272 self.graph.files_mut().register(Path::new(""))?
273 };
274
275 // Add edge to bidirectional store
276 self.graph
277 .edges_mut()
278 .add_edge(source, target, kind.clone(), file_id);
279 }
280
281 // Bump epoch to invalidate cursors
282 self.graph.bump_epoch();
283
284 Ok(())
285 }
286
287 /// Returns the number of pending nodes.
288 ///
289 /// # Example
290 ///
291 /// ```ignore
292 /// assert_eq!(txn.pending_nodes(), 5);
293 /// ```
294 #[must_use]
295 pub fn pending_nodes(&self) -> usize {
296 self.pending_nodes.len()
297 }
298
299 /// Returns the number of pending edges.
300 ///
301 /// # Example
302 ///
303 /// ```ignore
304 /// assert_eq!(txn.pending_edges(), 3);
305 /// ```
306 #[must_use]
307 pub fn pending_edges(&self) -> usize {
308 self.pending_edges.len()
309 }
310}
311
312impl Drop for GraphWriteTxn<'_> {
313 fn drop(&mut self) {
314 if !self.committed && (!self.pending_nodes.is_empty() || !self.pending_edges.is_empty()) {
315 // Log warning about uncommitted changes
316 log::warn!(
317 "GraphWriteTxn dropped without commit ({} nodes, {} edges discarded)",
318 self.pending_nodes.len(),
319 self.pending_edges.len()
320 );
321 }
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn test_txn_new() {
331 let mut graph = CodeGraph::new();
332 let txn = GraphWriteTxn::new(&mut graph);
333 assert_eq!(txn.pending_nodes(), 0);
334 assert_eq!(txn.pending_edges(), 0);
335 }
336
337 #[test]
338 fn test_txn_add_node() {
339 let mut graph = CodeGraph::new();
340 let mut txn = GraphWriteTxn::new(&mut graph);
341
342 let node_id = txn
343 .add_node(
344 Language::Rust,
345 "main.rs",
346 "main",
347 NodeKind::Function,
348 Some("fn main()".to_string()),
349 )
350 .expect("add_node");
351
352 assert_eq!(txn.pending_nodes(), 1);
353 assert_eq!(node_id.index(), 0);
354 }
355
356 #[test]
357 fn test_txn_add_edge() {
358 let mut graph = CodeGraph::new();
359 let mut txn = GraphWriteTxn::new(&mut graph);
360
361 let source = txn
362 .add_node(
363 Language::Rust,
364 "main.rs",
365 "caller",
366 NodeKind::Function,
367 None,
368 )
369 .expect("add_node");
370 let target = txn
371 .add_node(
372 Language::Rust,
373 "main.rs",
374 "callee",
375 NodeKind::Function,
376 None,
377 )
378 .expect("add_node");
379
380 let _edge_id = txn
381 .add_edge(
382 source,
383 target,
384 EdgeKind::Calls {
385 argument_count: 0,
386 is_async: false,
387 },
388 )
389 .expect("add_edge");
390
391 assert_eq!(txn.pending_nodes(), 2);
392 assert_eq!(txn.pending_edges(), 1);
393 }
394
395 #[test]
396 fn test_txn_commit_empty() {
397 let mut graph = CodeGraph::new();
398 let txn = GraphWriteTxn::new(&mut graph);
399 txn.commit().expect("commit empty txn");
400 }
401
402 #[test]
403 fn test_txn_commit_nodes() {
404 let mut graph = CodeGraph::new();
405 let initial_epoch = graph.epoch();
406
407 let mut txn = GraphWriteTxn::new(&mut graph);
408
409 txn.add_node(
410 Language::Rust,
411 "main.rs",
412 "main",
413 NodeKind::Function,
414 Some("fn main()".to_string()),
415 )
416 .expect("add_node");
417
418 txn.commit().expect("commit");
419
420 // Epoch should be incremented
421 assert_eq!(graph.epoch(), initial_epoch + 1);
422
423 // Node should exist in graph
424 assert_eq!(graph.nodes().len(), 1);
425 }
426
427 #[test]
428 fn test_txn_commit_edges() {
429 let mut graph = CodeGraph::new();
430 let mut txn = GraphWriteTxn::new(&mut graph);
431
432 let source = txn
433 .add_node(
434 Language::Rust,
435 "main.rs",
436 "caller",
437 NodeKind::Function,
438 None,
439 )
440 .expect("add_node");
441 let target = txn
442 .add_node(
443 Language::Rust,
444 "main.rs",
445 "callee",
446 NodeKind::Function,
447 None,
448 )
449 .expect("add_node");
450
451 txn.add_edge(
452 source,
453 target,
454 EdgeKind::Calls {
455 argument_count: 0,
456 is_async: false,
457 },
458 )
459 .expect("add_edge");
460
461 txn.commit().expect("commit");
462
463 assert_eq!(graph.nodes().len(), 2);
464 }
465}