formualizer_eval/engine/graph/editor/
transaction_manager.rs

1//! Transaction state management for dependency graph mutations
2//!
3//! This module provides:
4//! - TransactionManager: Manages transaction lifecycle and state
5//! - TransactionId: Unique identifier for transactions
6//! - Transaction: Internal state for active transactions
7
8use std::sync::atomic::{AtomicU64, Ordering};
9
10/// Unique identifier for a transaction
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct TransactionId(u64);
13
14impl Default for TransactionId {
15    fn default() -> Self {
16        Self::new()
17    }
18}
19
20impl TransactionId {
21    /// Create a new unique transaction ID
22    pub fn new() -> Self {
23        static COUNTER: AtomicU64 = AtomicU64::new(0);
24        TransactionId(COUNTER.fetch_add(1, Ordering::Relaxed))
25    }
26}
27
28impl std::fmt::Display for TransactionId {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "tx:{}", self.0)
31    }
32}
33
34/// Internal state for an active transaction
35#[derive(Debug)]
36struct Transaction {
37    id: TransactionId,
38    /// Index in change log where this transaction started
39    start_index: usize,
40    /// Named savepoints for partial rollback
41    savepoints: Vec<(String, usize)>,
42}
43
44/// Errors that can occur during transaction operations
45#[derive(Debug, Clone)]
46pub enum TransactionError {
47    /// A transaction is already active
48    AlreadyActive,
49    /// No transaction is currently active
50    NoActiveTransaction,
51    /// Transaction has grown too large
52    TransactionTooLarge { size: usize, max: usize },
53    /// Rollback operation failed
54    RollbackFailed(String),
55    /// Savepoint not found
56    SavepointNotFound(String),
57}
58
59impl std::fmt::Display for TransactionError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::AlreadyActive => write!(f, "Transaction already active"),
63            Self::NoActiveTransaction => write!(f, "No active transaction"),
64            Self::TransactionTooLarge { size, max } => {
65                write!(f, "Transaction too large: {size} > {max}")
66            }
67            Self::RollbackFailed(msg) => write!(f, "Rollback failed: {msg}"),
68            Self::SavepointNotFound(name) => write!(f, "Savepoint not found: {name}"),
69        }
70    }
71}
72
73impl std::error::Error for TransactionError {}
74
75/// Manages transaction state independently of graph mutations
76#[derive(Debug)]
77pub struct TransactionManager {
78    active_transaction: Option<Transaction>,
79    /// Maximum number of changes allowed in a single transaction
80    max_transaction_size: usize,
81}
82
83impl TransactionManager {
84    /// Create a new transaction manager with default settings
85    pub fn new() -> Self {
86        Self {
87            active_transaction: None,
88            max_transaction_size: 10_000, // Configurable limit
89        }
90    }
91
92    /// Create a transaction manager with custom size limit
93    pub fn with_max_size(max_size: usize) -> Self {
94        Self {
95            active_transaction: None,
96            max_transaction_size: max_size,
97        }
98    }
99
100    /// Begin a new transaction
101    ///
102    /// # Arguments
103    /// * `change_log_size` - Current size of the change log
104    ///
105    /// # Returns
106    /// The ID of the newly created transaction
107    ///
108    /// # Errors
109    /// Returns `AlreadyActive` if a transaction is already in progress
110    pub fn begin(&mut self, change_log_size: usize) -> Result<TransactionId, TransactionError> {
111        if self.active_transaction.is_some() {
112            return Err(TransactionError::AlreadyActive);
113        }
114
115        let id = TransactionId::new();
116        self.active_transaction = Some(Transaction {
117            id,
118            start_index: change_log_size,
119            savepoints: Vec::new(),
120        });
121        Ok(id)
122    }
123
124    /// Commit the current transaction
125    ///
126    /// # Returns
127    /// The ID of the committed transaction
128    ///
129    /// # Errors
130    /// Returns `NoActiveTransaction` if no transaction is active
131    pub fn commit(&mut self) -> Result<TransactionId, TransactionError> {
132        self.active_transaction
133            .take()
134            .map(|tx| tx.id)
135            .ok_or(TransactionError::NoActiveTransaction)
136    }
137
138    /// Get information needed for rollback and clear the transaction
139    ///
140    /// # Returns
141    /// A tuple of (transaction_id, start_index) for the transaction
142    ///
143    /// # Errors
144    /// Returns `NoActiveTransaction` if no transaction is active
145    pub fn rollback_info(&mut self) -> Result<(TransactionId, usize), TransactionError> {
146        self.active_transaction
147            .take()
148            .map(|tx| (tx.id, tx.start_index))
149            .ok_or(TransactionError::NoActiveTransaction)
150    }
151
152    /// Add a named savepoint to the current transaction
153    ///
154    /// # Arguments
155    /// * `name` - Name for the savepoint
156    /// * `change_log_size` - Current size of the change log
157    ///
158    /// # Errors
159    /// Returns `NoActiveTransaction` if no transaction is active
160    pub fn add_savepoint(
161        &mut self,
162        name: String,
163        change_log_size: usize,
164    ) -> Result<(), TransactionError> {
165        if let Some(tx) = &mut self.active_transaction {
166            tx.savepoints.push((name, change_log_size));
167            Ok(())
168        } else {
169            Err(TransactionError::NoActiveTransaction)
170        }
171    }
172
173    /// Get the index for a named savepoint
174    ///
175    /// # Arguments
176    /// * `name` - Name of the savepoint to find
177    ///
178    /// # Returns
179    /// The change log index where the savepoint was created
180    ///
181    /// # Errors
182    /// Returns `NoActiveTransaction` if no transaction is active
183    /// Returns `SavepointNotFound` if the named savepoint doesn't exist
184    pub fn get_savepoint(&self, name: &str) -> Result<usize, TransactionError> {
185        if let Some(tx) = &self.active_transaction {
186            tx.savepoints
187                .iter()
188                .find(|(n, _)| n == name)
189                .map(|(_, idx)| *idx)
190                .ok_or_else(|| TransactionError::SavepointNotFound(name.to_string()))
191        } else {
192            Err(TransactionError::NoActiveTransaction)
193        }
194    }
195
196    /// Remove savepoints after a given index (for partial rollback)
197    ///
198    /// # Arguments
199    /// * `index` - Remove all savepoints with index >= this value
200    pub fn truncate_savepoints(&mut self, index: usize) {
201        if let Some(tx) = &mut self.active_transaction {
202            tx.savepoints.retain(|(_, idx)| *idx < index);
203        }
204    }
205
206    /// Check if a transaction is currently active
207    pub fn is_active(&self) -> bool {
208        self.active_transaction.is_some()
209    }
210
211    /// Get the ID of the active transaction if any
212    pub fn active_id(&self) -> Option<TransactionId> {
213        self.active_transaction.as_ref().map(|tx| tx.id)
214    }
215
216    /// Check if transaction size is within limits
217    ///
218    /// # Arguments
219    /// * `change_log_size` - Current size of the change log
220    ///
221    /// # Errors
222    /// Returns `TransactionTooLarge` if size exceeds maximum
223    pub fn check_size(&self, change_log_size: usize) -> Result<(), TransactionError> {
224        if let Some(tx) = &self.active_transaction {
225            let tx_size = change_log_size - tx.start_index;
226            if tx_size > self.max_transaction_size {
227                return Err(TransactionError::TransactionTooLarge {
228                    size: tx_size,
229                    max: self.max_transaction_size,
230                });
231            }
232        }
233        Ok(())
234    }
235
236    /// Get the maximum transaction size limit
237    pub fn max_size(&self) -> usize {
238        self.max_transaction_size
239    }
240
241    /// Set the maximum transaction size limit
242    pub fn set_max_size(&mut self, max_size: usize) {
243        self.max_transaction_size = max_size;
244    }
245
246    /// Get the start index of the active transaction
247    pub fn start_index(&self) -> Option<usize> {
248        self.active_transaction.as_ref().map(|tx| tx.start_index)
249    }
250
251    /// Get all savepoints in the current transaction
252    pub fn savepoints(&self) -> Vec<(String, usize)> {
253        self.active_transaction
254            .as_ref()
255            .map(|tx| tx.savepoints.clone())
256            .unwrap_or_default()
257    }
258}
259
260impl Default for TransactionManager {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_transaction_id_uniqueness() {
272        let id1 = TransactionId::new();
273        let id2 = TransactionId::new();
274        assert_ne!(id1, id2);
275    }
276
277    #[test]
278    fn test_transaction_manager_lifecycle() {
279        let mut tm = TransactionManager::new();
280
281        // No transaction initially
282        assert!(!tm.is_active());
283        assert!(tm.commit().is_err());
284        assert!(tm.rollback_info().is_err());
285
286        // Begin transaction
287        let tx_id = tm.begin(0).unwrap();
288        assert!(tm.is_active());
289        assert_eq!(tm.active_id(), Some(tx_id));
290
291        // Cannot begin nested transaction
292        assert!(matches!(tm.begin(0), Err(TransactionError::AlreadyActive)));
293
294        // Commit transaction
295        let committed_id = tm.commit().unwrap();
296        assert_eq!(tx_id, committed_id);
297        assert!(!tm.is_active());
298
299        // No transaction after commit
300        assert!(tm.commit().is_err());
301    }
302
303    #[test]
304    fn test_transaction_rollback_info() {
305        let mut tm = TransactionManager::new();
306
307        // Begin transaction at index 42
308        let tx_id = tm.begin(42).unwrap();
309
310        // Get rollback info
311        let (rollback_id, start_index) = tm.rollback_info().unwrap();
312        assert_eq!(rollback_id, tx_id);
313        assert_eq!(start_index, 42);
314
315        // Transaction cleared after rollback_info
316        assert!(!tm.is_active());
317    }
318
319    #[test]
320    fn test_transaction_size_limits() {
321        let mut tm = TransactionManager::with_max_size(100);
322
323        tm.begin(0).unwrap();
324
325        // Within limit
326        assert!(tm.check_size(50).is_ok());
327        assert!(tm.check_size(100).is_ok());
328
329        // Exceeds limit
330        match tm.check_size(101) {
331            Err(TransactionError::TransactionTooLarge { size, max }) => {
332                assert_eq!(size, 101);
333                assert_eq!(max, 100);
334            }
335            _ => panic!("Expected TransactionTooLarge error"),
336        }
337    }
338
339    #[test]
340    fn test_transaction_savepoints() {
341        let mut tm = TransactionManager::new();
342
343        // Cannot add savepoint without transaction
344        assert!(tm.add_savepoint("test".to_string(), 10).is_err());
345
346        tm.begin(0).unwrap();
347
348        // Add savepoints
349        tm.add_savepoint("before_risky_op".to_string(), 10).unwrap();
350        tm.add_savepoint("after_risky_op".to_string(), 20).unwrap();
351        tm.add_savepoint("final".to_string(), 30).unwrap();
352
353        // Get savepoint
354        assert_eq!(tm.get_savepoint("before_risky_op").unwrap(), 10);
355        assert_eq!(tm.get_savepoint("after_risky_op").unwrap(), 20);
356        assert_eq!(tm.get_savepoint("final").unwrap(), 30);
357
358        // Non-existent savepoint
359        assert!(matches!(
360            tm.get_savepoint("missing"),
361            Err(TransactionError::SavepointNotFound(_))
362        ));
363
364        // List all savepoints
365        let savepoints = tm.savepoints();
366        assert_eq!(savepoints.len(), 3);
367        assert_eq!(savepoints[0].0, "before_risky_op");
368        assert_eq!(savepoints[0].1, 10);
369    }
370
371    #[test]
372    fn test_truncate_savepoints() {
373        let mut tm = TransactionManager::new();
374        tm.begin(0).unwrap();
375
376        tm.add_savepoint("sp1".to_string(), 10).unwrap();
377        tm.add_savepoint("sp2".to_string(), 20).unwrap();
378        tm.add_savepoint("sp3".to_string(), 30).unwrap();
379
380        // Truncate savepoints >= index 20
381        tm.truncate_savepoints(20);
382
383        // Only first savepoint remains
384        let savepoints = tm.savepoints();
385        assert_eq!(savepoints.len(), 1);
386        assert_eq!(savepoints[0].0, "sp1");
387
388        // sp2 and sp3 were removed
389        assert!(tm.get_savepoint("sp2").is_err());
390        assert!(tm.get_savepoint("sp3").is_err());
391    }
392
393    #[test]
394    fn test_max_size_configuration() {
395        let mut tm = TransactionManager::new();
396        assert_eq!(tm.max_size(), 10_000);
397
398        tm.set_max_size(500);
399        assert_eq!(tm.max_size(), 500);
400
401        // New limit applies to size checks
402        tm.begin(0).unwrap();
403        assert!(tm.check_size(499).is_ok());
404        assert!(tm.check_size(501).is_err());
405    }
406
407    #[test]
408    fn test_start_index_tracking() {
409        let mut tm = TransactionManager::new();
410
411        // No start index without transaction
412        assert_eq!(tm.start_index(), None);
413
414        // Start index tracked during transaction
415        tm.begin(123).unwrap();
416        assert_eq!(tm.start_index(), Some(123));
417
418        // Cleared after commit
419        tm.commit().unwrap();
420        assert_eq!(tm.start_index(), None);
421    }
422
423    #[test]
424    fn test_error_display() {
425        let err = TransactionError::AlreadyActive;
426        assert_eq!(format!("{err}"), "Transaction already active");
427
428        let err = TransactionError::NoActiveTransaction;
429        assert_eq!(format!("{err}"), "No active transaction");
430
431        let err = TransactionError::TransactionTooLarge {
432            size: 150,
433            max: 100,
434        };
435        assert_eq!(format!("{err}"), "Transaction too large: 150 > 100");
436
437        let err = TransactionError::RollbackFailed("test error".to_string());
438        assert_eq!(format!("{err}"), "Rollback failed: test error");
439
440        let err = TransactionError::SavepointNotFound("missing".to_string());
441        assert_eq!(format!("{err}"), "Savepoint not found: missing");
442    }
443
444    #[test]
445    fn test_transaction_id_display() {
446        let id = TransactionId(42);
447        assert_eq!(format!("{id}"), "tx:42");
448    }
449}