Skip to main content

ipfrs_storage/
transaction_log.rs

1//! ACID-like transaction logging with commit, rollback, and replay support.
2//!
3//! This module provides [`StorageTransactionLog`], a production-grade transaction
4//! log for tracking storage operations with full ACID semantics at the log level:
5//!
6//! - **Atomicity**: each transaction either commits all operations or none
7//! - **Consistency**: state transitions are explicit and guarded
8//! - **Isolation**: active transactions are tracked independently
9//! - **Durability**: committed entries are retained in a bounded deque for replay
10//!
11//! # Example
12//!
13//! ```rust
14//! use ipfrs_storage::transaction_log::{StorageTransactionLog, TxOperation};
15//!
16//! let mut log = StorageTransactionLog::new(1024);
17//! let tx_id = log.begin(0);
18//! log.append(tx_id, TxOperation::Put {
19//!     key: "foo".to_string(),
20//!     value: b"bar".to_vec(),
21//!     prev_value: None,
22//! }).unwrap();
23//! log.commit(tx_id, 1).unwrap();
24//! assert_eq!(log.committed_count_total(), 1);
25//! ```
26
27use std::collections::{HashMap, VecDeque};
28use thiserror::Error;
29
30// ──────────────────────────────────────────────────────────────────────────────
31// Public error type
32// ──────────────────────────────────────────────────────────────────────────────
33
34/// Errors returned by [`StorageTransactionLog`] operations.
35#[derive(Debug, Error, PartialEq, Eq, Clone)]
36pub enum TxError {
37    /// No transaction with the given id exists in active or committed storage.
38    #[error("transaction {0} not found")]
39    TransactionNotFound(u64),
40
41    /// The transaction exists but is not in the `Active` state.
42    #[error("transaction {0} is not active")]
43    TransactionNotActive(u64),
44
45    /// The transaction has already been committed, rolled back, or aborted.
46    #[error("transaction {0} has already ended")]
47    TransactionAlreadyEnded(u64),
48}
49
50// ──────────────────────────────────────────────────────────────────────────────
51// Core types
52// ──────────────────────────────────────────────────────────────────────────────
53
54/// A monotonically increasing transaction identifier.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
56pub struct TransactionId(pub u64);
57
58impl TransactionId {
59    /// Returns the inner numeric id.
60    #[inline]
61    pub fn as_u64(self) -> u64 {
62        self.0
63    }
64}
65
66impl std::fmt::Display for TransactionId {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        write!(f, "Tx({})", self.0)
69    }
70}
71
72/// A single operation recorded inside a transaction.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum TxOperation {
75    /// Insert or overwrite a key.  `prev_value` holds the prior content for rollback.
76    Put {
77        key: String,
78        value: Vec<u8>,
79        prev_value: Option<Vec<u8>>,
80    },
81    /// Remove a key.  `prev_value` holds the prior content for rollback.
82    Delete { key: String, prev_value: Vec<u8> },
83    /// Insert or overwrite multiple keys atomically.
84    BatchPut { entries: Vec<(String, Vec<u8>)> },
85}
86
87impl TxOperation {
88    /// Returns the number of logical key-value pairs affected by this operation.
89    pub fn key_count(&self) -> usize {
90        match self {
91            TxOperation::Put { .. } => 1,
92            TxOperation::Delete { .. } => 1,
93            TxOperation::BatchPut { entries } => entries.len(),
94        }
95    }
96}
97
98/// Lifecycle state of a transaction.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub enum TransactionStatus {
101    /// Transaction is open and accepting operations.
102    Active,
103    /// Transaction has been successfully committed.
104    Committed,
105    /// Transaction was rolled back; caller undid the operations.
106    RolledBack,
107    /// Transaction was aborted without rollback data.
108    Aborted,
109}
110
111impl std::fmt::Display for TransactionStatus {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            TransactionStatus::Active => write!(f, "Active"),
115            TransactionStatus::Committed => write!(f, "Committed"),
116            TransactionStatus::RolledBack => write!(f, "RolledBack"),
117            TransactionStatus::Aborted => write!(f, "Aborted"),
118        }
119    }
120}
121
122/// A single transaction with its complete operation log and lifecycle metadata.
123#[derive(Debug, Clone)]
124pub struct Transaction {
125    /// Unique identifier assigned at begin time.
126    pub id: TransactionId,
127    /// Ordered list of operations appended to this transaction.
128    pub operations: Vec<TxOperation>,
129    /// Current lifecycle state.
130    pub status: TransactionStatus,
131    /// Logical timestamp (caller-supplied) when the transaction was started.
132    pub started_at: u64,
133    /// Logical timestamp (caller-supplied) when the transaction ended, if it has.
134    pub ended_at: Option<u64>,
135}
136
137impl Transaction {
138    fn new(id: TransactionId, started_at: u64) -> Self {
139        Self {
140            id,
141            operations: Vec::new(),
142            status: TransactionStatus::Active,
143            started_at,
144            ended_at: None,
145        }
146    }
147
148    /// Returns `true` if the transaction is still accepting operations.
149    #[inline]
150    pub fn is_active(&self) -> bool {
151        self.status == TransactionStatus::Active
152    }
153
154    /// Total number of operations recorded.
155    #[inline]
156    pub fn operation_count(&self) -> usize {
157        self.operations.len()
158    }
159}
160
161// ──────────────────────────────────────────────────────────────────────────────
162// Aggregate statistics
163// ──────────────────────────────────────────────────────────────────────────────
164
165/// Aggregate statistics snapshot for a [`StorageTransactionLog`].
166#[derive(Debug, Clone, PartialEq)]
167pub struct TxStats {
168    /// Number of currently active (in-flight) transactions.
169    pub active_count: usize,
170    /// Total number of transactions that have been committed (cumulative counter).
171    pub committed_count: u64,
172    /// Total number of transactions that have been rolled back (cumulative counter).
173    pub rolled_back_count: u64,
174    /// Total operations across all committed transactions currently held in the deque.
175    pub total_operations: u64,
176    /// Average operations per committed transaction in the deque; `0.0` when deque is empty.
177    pub avg_ops_per_tx: f64,
178}
179
180// ──────────────────────────────────────────────────────────────────────────────
181// Main struct
182// ──────────────────────────────────────────────────────────────────────────────
183
184/// ACID-like transaction log with commit, rollback, and replay support.
185///
186/// Committed transactions are stored in a bounded [`VecDeque`].  When the deque
187/// exceeds `max_committed`, the oldest committed entry is evicted to bound memory
188/// usage.  Active transactions live in a separate [`HashMap`] so lookup is O(1).
189///
190/// All timestamps are *caller-supplied* logical clocks (e.g. milliseconds since
191/// epoch), so the struct itself is `Send + Sync` and has no hidden I/O.
192pub struct StorageTransactionLog {
193    /// Ring buffer of committed transactions, oldest at front.
194    transactions: VecDeque<Transaction>,
195    /// In-flight transactions indexed by id.
196    active: HashMap<TransactionId, Transaction>,
197    /// Counter used to hand out the next transaction id.
198    next_id: u64,
199    /// Maximum number of committed transactions to retain.
200    max_committed: usize,
201    /// Cumulative committed counter (never decreases).
202    committed_count: u64,
203    /// Cumulative rolled-back counter (never decreases).
204    rolled_back_count: u64,
205}
206
207impl StorageTransactionLog {
208    // ──────────────────────────────────────────────────────────────────────
209    // Construction
210    // ──────────────────────────────────────────────────────────────────────
211
212    /// Create a new transaction log.
213    ///
214    /// `max_committed` controls how many committed [`Transaction`] records are
215    /// retained for replay.  A value of `0` means no committed records are
216    /// retained (replay will always return an empty list).
217    pub fn new(max_committed: usize) -> Self {
218        Self {
219            transactions: VecDeque::new(),
220            active: HashMap::new(),
221            next_id: 1,
222            max_committed,
223            committed_count: 0,
224            rolled_back_count: 0,
225        }
226    }
227
228    // ──────────────────────────────────────────────────────────────────────
229    // Transaction lifecycle
230    // ──────────────────────────────────────────────────────────────────────
231
232    /// Begin a new transaction and return its [`TransactionId`].
233    ///
234    /// `now` is the caller-supplied logical timestamp for `started_at`.
235    pub fn begin(&mut self, now: u64) -> TransactionId {
236        let id = TransactionId(self.next_id);
237        self.next_id += 1;
238        let tx = Transaction::new(id, now);
239        self.active.insert(id, tx);
240        id
241    }
242
243    /// Append an operation to an active transaction.
244    ///
245    /// # Errors
246    ///
247    /// - [`TxError::TransactionNotFound`] – no transaction with `tx_id` exists
248    /// - [`TxError::TransactionNotActive`] – the transaction is no longer active
249    pub fn append(&mut self, tx_id: TransactionId, op: TxOperation) -> Result<(), TxError> {
250        let tx = self
251            .active
252            .get_mut(&tx_id)
253            .ok_or(TxError::TransactionNotFound(tx_id.0))?;
254        if !tx.is_active() {
255            return Err(TxError::TransactionNotActive(tx_id.0));
256        }
257        tx.operations.push(op);
258        Ok(())
259    }
260
261    /// Commit a transaction, moving it to the committed deque.
262    ///
263    /// If the deque has reached `max_committed`, the oldest entry is evicted
264    /// before the new one is pushed.
265    ///
266    /// # Errors
267    ///
268    /// - [`TxError::TransactionNotFound`] – no active transaction with `tx_id`
269    /// - [`TxError::TransactionAlreadyEnded`] – the transaction is not active
270    pub fn commit(&mut self, tx_id: TransactionId, now: u64) -> Result<(), TxError> {
271        let mut tx = self
272            .active
273            .remove(&tx_id)
274            .ok_or(TxError::TransactionNotFound(tx_id.0))?;
275        if !tx.is_active() {
276            // Put it back so state is consistent, then return error.
277            self.active.insert(tx_id, tx);
278            return Err(TxError::TransactionAlreadyEnded(tx_id.0));
279        }
280        tx.status = TransactionStatus::Committed;
281        tx.ended_at = Some(now);
282        self.committed_count += 1;
283
284        // Evict oldest when we are at capacity.
285        if self.max_committed > 0 && self.transactions.len() >= self.max_committed {
286            self.transactions.pop_front();
287        }
288        if self.max_committed > 0 {
289            self.transactions.push_back(tx);
290        }
291        // If max_committed == 0 the tx is simply discarded (not retained).
292        Ok(())
293    }
294
295    /// Roll back a transaction, returning its operations in **reverse** order.
296    ///
297    /// The caller is expected to use the returned operations to undo changes
298    /// (e.g. restore `prev_value` for `Put`/`Delete` entries).
299    ///
300    /// # Errors
301    ///
302    /// - [`TxError::TransactionNotFound`] – no active transaction with `tx_id`
303    /// - [`TxError::TransactionAlreadyEnded`] – the transaction is not active
304    pub fn rollback(
305        &mut self,
306        tx_id: TransactionId,
307        now: u64,
308    ) -> Result<Vec<TxOperation>, TxError> {
309        let mut tx = self
310            .active
311            .remove(&tx_id)
312            .ok_or(TxError::TransactionNotFound(tx_id.0))?;
313        if !tx.is_active() {
314            self.active.insert(tx_id, tx);
315            return Err(TxError::TransactionAlreadyEnded(tx_id.0));
316        }
317        tx.status = TransactionStatus::RolledBack;
318        tx.ended_at = Some(now);
319        self.rolled_back_count += 1;
320
321        // Return operations in reverse order for undo.
322        let mut ops = tx.operations.clone();
323        ops.reverse();
324        // We intentionally do not retain rolled-back transactions in the replay
325        // deque because they were never durably applied.
326        Ok(ops)
327    }
328
329    /// Abort a transaction without providing rollback data to the caller.
330    ///
331    /// Unlike [`rollback`](Self::rollback), this method discards the operations
332    /// and does **not** return them.  Use this when the transaction state is
333    /// already inconsistent or when rollback undo is handled elsewhere.
334    ///
335    /// # Errors
336    ///
337    /// - [`TxError::TransactionNotFound`] – no active transaction with `tx_id`
338    /// - [`TxError::TransactionAlreadyEnded`] – the transaction is not active
339    pub fn abort(&mut self, tx_id: TransactionId, now: u64) -> Result<(), TxError> {
340        let mut tx = self
341            .active
342            .remove(&tx_id)
343            .ok_or(TxError::TransactionNotFound(tx_id.0))?;
344        if !tx.is_active() {
345            self.active.insert(tx_id, tx);
346            return Err(TxError::TransactionAlreadyEnded(tx_id.0));
347        }
348        tx.status = TransactionStatus::Aborted;
349        tx.ended_at = Some(now);
350        // Aborted transactions are not counted in rolled_back_count and are not
351        // retained.
352        Ok(())
353    }
354
355    // ──────────────────────────────────────────────────────────────────────
356    // Query / replay
357    // ──────────────────────────────────────────────────────────────────────
358
359    /// Returns all committed transactions whose id is **strictly greater** than
360    /// `since_id`, in ascending id order.
361    ///
362    /// This is the primary replay API: supply `TransactionId(0)` to replay from
363    /// the beginning of the retained window.
364    pub fn replay_committed(&self, since_id: TransactionId) -> Vec<&Transaction> {
365        self.transactions
366            .iter()
367            .filter(|tx| tx.id > since_id)
368            .collect()
369    }
370
371    /// Look up a transaction by id.
372    ///
373    /// Searches the active map first, then the committed deque.  Returns `None`
374    /// if the transaction has been evicted or was never created.
375    pub fn get_transaction(&self, tx_id: TransactionId) -> Option<&Transaction> {
376        if let Some(tx) = self.active.get(&tx_id) {
377            return Some(tx);
378        }
379        self.transactions.iter().find(|tx| tx.id == tx_id)
380    }
381
382    /// Returns references to all currently active (in-flight) transactions,
383    /// sorted by id for deterministic ordering.
384    pub fn active_transactions(&self) -> Vec<&Transaction> {
385        let mut txs: Vec<&Transaction> = self.active.values().collect();
386        txs.sort_by_key(|tx| tx.id);
387        txs
388    }
389
390    /// Number of currently active transactions.
391    #[inline]
392    pub fn active_count(&self) -> usize {
393        self.active.len()
394    }
395
396    /// Cumulative count of all committed transactions since this log was created.
397    #[inline]
398    pub fn committed_count_total(&self) -> u64 {
399        self.committed_count
400    }
401
402    /// Cumulative count of all rolled-back transactions since this log was created.
403    #[inline]
404    pub fn rolled_back_count_total(&self) -> u64 {
405        self.rolled_back_count
406    }
407
408    /// Returns a statistics snapshot.
409    ///
410    /// `total_operations` counts the sum of operations across all committed
411    /// transactions currently held in the deque (not across all historical
412    /// transactions).
413    pub fn stats(&self) -> TxStats {
414        let total_operations: u64 = self
415            .transactions
416            .iter()
417            .map(|tx| tx.operations.len() as u64)
418            .sum();
419        let deque_len = self.transactions.len();
420        let avg_ops_per_tx = if deque_len == 0 {
421            0.0
422        } else {
423            total_operations as f64 / deque_len as f64
424        };
425        TxStats {
426            active_count: self.active.len(),
427            committed_count: self.committed_count,
428            rolled_back_count: self.rolled_back_count,
429            total_operations,
430            avg_ops_per_tx,
431        }
432    }
433
434    // ──────────────────────────────────────────────────────────────────────
435    // Utility
436    // ──────────────────────────────────────────────────────────────────────
437
438    /// Returns the number of committed transactions currently in the deque.
439    #[inline]
440    pub fn committed_retained(&self) -> usize {
441        self.transactions.len()
442    }
443
444    /// Returns the configured maximum number of retained committed transactions.
445    #[inline]
446    pub fn max_committed(&self) -> usize {
447        self.max_committed
448    }
449
450    /// Returns the id that will be assigned to the **next** transaction.
451    #[inline]
452    pub fn next_transaction_id(&self) -> TransactionId {
453        TransactionId(self.next_id)
454    }
455
456    /// Drain all committed transactions from the deque and return them.
457    ///
458    /// This can be used to persist committed transactions to durable storage.
459    pub fn drain_committed(&mut self) -> Vec<Transaction> {
460        self.transactions.drain(..).collect()
461    }
462}
463
464// ──────────────────────────────────────────────────────────────────────────────
465// Tests
466// ──────────────────────────────────────────────────────────────────────────────
467
468#[cfg(test)]
469mod tests {
470    use crate::transaction_log::{
471        StorageTransactionLog, TransactionId, TransactionStatus, TxError, TxOperation, TxStats,
472    };
473
474    // ── helpers ──────────────────────────────────────────────────────────────
475
476    fn put_op(key: &str, value: &[u8]) -> TxOperation {
477        TxOperation::Put {
478            key: key.to_string(),
479            value: value.to_vec(),
480            prev_value: None,
481        }
482    }
483
484    fn put_op_with_prev(key: &str, value: &[u8], prev: &[u8]) -> TxOperation {
485        TxOperation::Put {
486            key: key.to_string(),
487            value: value.to_vec(),
488            prev_value: Some(prev.to_vec()),
489        }
490    }
491
492    fn delete_op(key: &str, prev: &[u8]) -> TxOperation {
493        TxOperation::Delete {
494            key: key.to_string(),
495            prev_value: prev.to_vec(),
496        }
497    }
498
499    fn batch_put_op(entries: &[(&str, &[u8])]) -> TxOperation {
500        TxOperation::BatchPut {
501            entries: entries
502                .iter()
503                .map(|(k, v)| (k.to_string(), v.to_vec()))
504                .collect(),
505        }
506    }
507
508    // ── 1. Construction ───────────────────────────────────────────────────────
509
510    #[test]
511    fn test_new_log_is_empty() {
512        let log = StorageTransactionLog::new(100);
513        assert_eq!(log.active_count(), 0);
514        assert_eq!(log.committed_count_total(), 0);
515        assert_eq!(log.rolled_back_count_total(), 0);
516        assert_eq!(log.committed_retained(), 0);
517    }
518
519    #[test]
520    fn test_max_committed_preserved() {
521        let log = StorageTransactionLog::new(42);
522        assert_eq!(log.max_committed(), 42);
523    }
524
525    #[test]
526    fn test_next_id_starts_at_one() {
527        let log = StorageTransactionLog::new(10);
528        assert_eq!(log.next_transaction_id(), TransactionId(1));
529    }
530
531    // ── 2. begin ─────────────────────────────────────────────────────────────
532
533    #[test]
534    fn test_begin_returns_incrementing_ids() {
535        let mut log = StorageTransactionLog::new(10);
536        let id1 = log.begin(0);
537        let id2 = log.begin(1);
538        let id3 = log.begin(2);
539        assert_eq!(id1, TransactionId(1));
540        assert_eq!(id2, TransactionId(2));
541        assert_eq!(id3, TransactionId(3));
542    }
543
544    #[test]
545    fn test_begin_increments_active_count() {
546        let mut log = StorageTransactionLog::new(10);
547        log.begin(0);
548        assert_eq!(log.active_count(), 1);
549        log.begin(0);
550        assert_eq!(log.active_count(), 2);
551    }
552
553    #[test]
554    fn test_begin_transaction_is_active_status() {
555        let mut log = StorageTransactionLog::new(10);
556        let id = log.begin(100);
557        let tx = log.get_transaction(id).expect("tx must exist");
558        assert_eq!(tx.status, TransactionStatus::Active);
559        assert_eq!(tx.started_at, 100);
560        assert!(tx.ended_at.is_none());
561    }
562
563    // ── 3. append ────────────────────────────────────────────────────────────
564
565    #[test]
566    fn test_append_put_operation() {
567        let mut log = StorageTransactionLog::new(10);
568        let id = log.begin(0);
569        let result = log.append(id, put_op("k", b"v"));
570        assert!(result.is_ok());
571        let tx = log.get_transaction(id).expect("tx must exist");
572        assert_eq!(tx.operations.len(), 1);
573    }
574
575    #[test]
576    fn test_append_multiple_operations() {
577        let mut log = StorageTransactionLog::new(10);
578        let id = log.begin(0);
579        log.append(id, put_op("a", b"1")).unwrap();
580        log.append(id, put_op("b", b"2")).unwrap();
581        log.append(id, delete_op("c", b"old")).unwrap();
582        let tx = log.get_transaction(id).expect("tx must exist");
583        assert_eq!(tx.operations.len(), 3);
584    }
585
586    #[test]
587    fn test_append_unknown_tx_returns_not_found() {
588        let mut log = StorageTransactionLog::new(10);
589        let err = log
590            .append(TransactionId(99), put_op("k", b"v"))
591            .unwrap_err();
592        assert_eq!(err, TxError::TransactionNotFound(99));
593    }
594
595    #[test]
596    fn test_append_batch_put_operation() {
597        let mut log = StorageTransactionLog::new(10);
598        let id = log.begin(0);
599        let op = batch_put_op(&[("x", b"1"), ("y", b"2"), ("z", b"3")]);
600        log.append(id, op).unwrap();
601        let tx = log.get_transaction(id).unwrap();
602        assert_eq!(tx.operations.len(), 1);
603        if let TxOperation::BatchPut { entries } = &tx.operations[0] {
604            assert_eq!(entries.len(), 3);
605        } else {
606            panic!("expected BatchPut");
607        }
608    }
609
610    // ── 4. commit ────────────────────────────────────────────────────────────
611
612    #[test]
613    fn test_commit_moves_tx_to_committed() {
614        let mut log = StorageTransactionLog::new(10);
615        let id = log.begin(0);
616        log.append(id, put_op("k", b"v")).unwrap();
617        log.commit(id, 1).unwrap();
618        assert_eq!(log.active_count(), 0);
619        assert_eq!(log.committed_count_total(), 1);
620        assert_eq!(log.committed_retained(), 1);
621    }
622
623    #[test]
624    fn test_commit_sets_ended_at() {
625        let mut log = StorageTransactionLog::new(10);
626        let id = log.begin(0);
627        log.commit(id, 42).unwrap();
628        let tx = log.get_transaction(id).expect("committed tx visible");
629        assert_eq!(tx.ended_at, Some(42));
630        assert_eq!(tx.status, TransactionStatus::Committed);
631    }
632
633    #[test]
634    fn test_commit_unknown_tx_returns_not_found() {
635        let mut log = StorageTransactionLog::new(10);
636        let err = log.commit(TransactionId(77), 0).unwrap_err();
637        assert_eq!(err, TxError::TransactionNotFound(77));
638    }
639
640    #[test]
641    fn test_commit_evicts_oldest_when_at_capacity() {
642        let mut log = StorageTransactionLog::new(3);
643        let ids: Vec<_> = (0..4).map(|t| log.begin(t)).collect();
644        for (i, &id) in ids.iter().enumerate() {
645            log.commit(id, i as u64 + 10).unwrap();
646        }
647        assert_eq!(log.committed_retained(), 3);
648        // Oldest (id=1) should have been evicted.
649        assert!(log.get_transaction(TransactionId(1)).is_none());
650        // Others remain.
651        assert!(log.get_transaction(TransactionId(2)).is_some());
652        assert!(log.get_transaction(TransactionId(3)).is_some());
653        assert!(log.get_transaction(TransactionId(4)).is_some());
654    }
655
656    #[test]
657    fn test_commit_with_max_zero_retains_nothing() {
658        let mut log = StorageTransactionLog::new(0);
659        let id = log.begin(0);
660        log.commit(id, 1).unwrap();
661        assert_eq!(log.committed_retained(), 0);
662        assert_eq!(log.committed_count_total(), 1);
663    }
664
665    // ── 5. rollback ──────────────────────────────────────────────────────────
666
667    #[test]
668    fn test_rollback_returns_ops_in_reverse() {
669        let mut log = StorageTransactionLog::new(10);
670        let id = log.begin(0);
671        log.append(id, put_op("a", b"1")).unwrap();
672        log.append(id, put_op("b", b"2")).unwrap();
673        log.append(id, delete_op("c", b"old")).unwrap();
674        let ops = log.rollback(id, 5).unwrap();
675        assert_eq!(ops.len(), 3);
676        // Last appended op should be first in the returned list.
677        assert_eq!(ops[0], delete_op("c", b"old"));
678        assert_eq!(ops[1], put_op("b", b"2"));
679        assert_eq!(ops[2], put_op("a", b"1"));
680    }
681
682    #[test]
683    fn test_rollback_increments_rolled_back_count() {
684        let mut log = StorageTransactionLog::new(10);
685        let id = log.begin(0);
686        log.rollback(id, 1).unwrap();
687        assert_eq!(log.rolled_back_count_total(), 1);
688    }
689
690    #[test]
691    fn test_rollback_removes_from_active() {
692        let mut log = StorageTransactionLog::new(10);
693        let id = log.begin(0);
694        log.rollback(id, 1).unwrap();
695        assert_eq!(log.active_count(), 0);
696    }
697
698    #[test]
699    fn test_rollback_does_not_add_to_committed_deque() {
700        let mut log = StorageTransactionLog::new(10);
701        let id = log.begin(0);
702        log.rollback(id, 1).unwrap();
703        assert_eq!(log.committed_retained(), 0);
704    }
705
706    #[test]
707    fn test_rollback_unknown_tx_returns_not_found() {
708        let mut log = StorageTransactionLog::new(10);
709        let err = log.rollback(TransactionId(5), 0).unwrap_err();
710        assert_eq!(err, TxError::TransactionNotFound(5));
711    }
712
713    #[test]
714    fn test_rollback_empty_tx_returns_empty_ops() {
715        let mut log = StorageTransactionLog::new(10);
716        let id = log.begin(0);
717        let ops = log.rollback(id, 1).unwrap();
718        assert!(ops.is_empty());
719    }
720
721    // ── 6. abort ─────────────────────────────────────────────────────────────
722
723    #[test]
724    fn test_abort_removes_from_active() {
725        let mut log = StorageTransactionLog::new(10);
726        let id = log.begin(0);
727        log.abort(id, 1).unwrap();
728        assert_eq!(log.active_count(), 0);
729    }
730
731    #[test]
732    fn test_abort_does_not_increment_committed_count() {
733        let mut log = StorageTransactionLog::new(10);
734        let id = log.begin(0);
735        log.abort(id, 1).unwrap();
736        assert_eq!(log.committed_count_total(), 0);
737    }
738
739    #[test]
740    fn test_abort_does_not_increment_rolled_back_count() {
741        let mut log = StorageTransactionLog::new(10);
742        let id = log.begin(0);
743        log.abort(id, 1).unwrap();
744        assert_eq!(log.rolled_back_count_total(), 0);
745    }
746
747    #[test]
748    fn test_abort_unknown_tx_returns_not_found() {
749        let mut log = StorageTransactionLog::new(10);
750        let err = log.abort(TransactionId(9), 0).unwrap_err();
751        assert_eq!(err, TxError::TransactionNotFound(9));
752    }
753
754    #[test]
755    fn test_abort_does_not_retain_in_deque() {
756        let mut log = StorageTransactionLog::new(10);
757        let id = log.begin(0);
758        log.abort(id, 1).unwrap();
759        assert_eq!(log.committed_retained(), 0);
760    }
761
762    // ── 7. replay_committed ──────────────────────────────────────────────────
763
764    #[test]
765    fn test_replay_committed_since_zero_returns_all() {
766        let mut log = StorageTransactionLog::new(10);
767        let id1 = log.begin(0);
768        log.commit(id1, 1).unwrap();
769        let id2 = log.begin(2);
770        log.commit(id2, 3).unwrap();
771        let replayed = log.replay_committed(TransactionId(0));
772        assert_eq!(replayed.len(), 2);
773    }
774
775    #[test]
776    fn test_replay_committed_filters_by_id() {
777        let mut log = StorageTransactionLog::new(10);
778        let id1 = log.begin(0);
779        log.commit(id1, 1).unwrap();
780        let id2 = log.begin(2);
781        log.commit(id2, 3).unwrap();
782        let id3 = log.begin(4);
783        log.commit(id3, 5).unwrap();
784        // Replay only after id1 (i.e., id ≥ 2)
785        let replayed = log.replay_committed(id1);
786        assert_eq!(replayed.len(), 2);
787        assert_eq!(replayed[0].id, id2);
788        assert_eq!(replayed[1].id, id3);
789    }
790
791    #[test]
792    fn test_replay_committed_returns_empty_for_high_since_id() {
793        let mut log = StorageTransactionLog::new(10);
794        let id = log.begin(0);
795        log.commit(id, 1).unwrap();
796        let replayed = log.replay_committed(TransactionId(999));
797        assert!(replayed.is_empty());
798    }
799
800    #[test]
801    fn test_replay_committed_order_is_ascending() {
802        let mut log = StorageTransactionLog::new(10);
803        for t in 0..5u64 {
804            let id = log.begin(t);
805            log.commit(id, t + 1).unwrap();
806        }
807        let replayed = log.replay_committed(TransactionId(0));
808        let ids: Vec<u64> = replayed.iter().map(|tx| tx.id.0).collect();
809        let mut sorted = ids.clone();
810        sorted.sort_unstable();
811        assert_eq!(ids, sorted);
812    }
813
814    // ── 8. get_transaction ───────────────────────────────────────────────────
815
816    #[test]
817    fn test_get_transaction_active() {
818        let mut log = StorageTransactionLog::new(10);
819        let id = log.begin(7);
820        let tx = log.get_transaction(id).expect("should find active tx");
821        assert_eq!(tx.id, id);
822        assert_eq!(tx.status, TransactionStatus::Active);
823    }
824
825    #[test]
826    fn test_get_transaction_committed() {
827        let mut log = StorageTransactionLog::new(10);
828        let id = log.begin(0);
829        log.commit(id, 1).unwrap();
830        let tx = log.get_transaction(id).expect("should find committed tx");
831        assert_eq!(tx.status, TransactionStatus::Committed);
832    }
833
834    #[test]
835    fn test_get_transaction_returns_none_for_unknown() {
836        let log = StorageTransactionLog::new(10);
837        assert!(log.get_transaction(TransactionId(42)).is_none());
838    }
839
840    // ── 9. active_transactions ───────────────────────────────────────────────
841
842    #[test]
843    fn test_active_transactions_sorted_by_id() {
844        let mut log = StorageTransactionLog::new(10);
845        let _id3 = log.begin(2);
846        let _id1 = log.begin(0);
847        let _id2 = log.begin(1);
848        let active = log.active_transactions();
849        let ids: Vec<u64> = active.iter().map(|tx| tx.id.0).collect();
850        let mut sorted = ids.clone();
851        sorted.sort_unstable();
852        assert_eq!(ids, sorted);
853    }
854
855    #[test]
856    fn test_active_transactions_empty_after_all_committed() {
857        let mut log = StorageTransactionLog::new(10);
858        let ids: Vec<_> = (0..3).map(|t| log.begin(t)).collect();
859        for id in ids {
860            log.commit(id, 99).unwrap();
861        }
862        assert!(log.active_transactions().is_empty());
863    }
864
865    // ── 10. stats ────────────────────────────────────────────────────────────
866
867    #[test]
868    fn test_stats_initial() {
869        let log = StorageTransactionLog::new(10);
870        let s = log.stats();
871        assert_eq!(
872            s,
873            TxStats {
874                active_count: 0,
875                committed_count: 0,
876                rolled_back_count: 0,
877                total_operations: 0,
878                avg_ops_per_tx: 0.0,
879            }
880        );
881    }
882
883    #[test]
884    fn test_stats_reflects_active_and_committed() {
885        let mut log = StorageTransactionLog::new(10);
886        let id1 = log.begin(0);
887        log.append(id1, put_op("a", b"1")).unwrap();
888        log.append(id1, put_op("b", b"2")).unwrap();
889        log.commit(id1, 1).unwrap();
890
891        let _id2 = log.begin(2);
892
893        let s = log.stats();
894        assert_eq!(s.active_count, 1);
895        assert_eq!(s.committed_count, 1);
896        assert_eq!(s.rolled_back_count, 0);
897        assert_eq!(s.total_operations, 2);
898        assert!((s.avg_ops_per_tx - 2.0).abs() < f64::EPSILON);
899    }
900
901    #[test]
902    fn test_stats_avg_ops_zero_when_no_committed_in_deque() {
903        let mut log = StorageTransactionLog::new(0); // no retention
904        let id = log.begin(0);
905        log.append(id, put_op("k", b"v")).unwrap();
906        log.commit(id, 1).unwrap();
907        let s = log.stats();
908        assert_eq!(s.avg_ops_per_tx, 0.0);
909        assert_eq!(s.total_operations, 0);
910    }
911
912    // ── 11. TxOperation helpers ──────────────────────────────────────────────
913
914    #[test]
915    fn test_tx_operation_key_count_put() {
916        assert_eq!(put_op("k", b"v").key_count(), 1);
917    }
918
919    #[test]
920    fn test_tx_operation_key_count_delete() {
921        assert_eq!(delete_op("k", b"v").key_count(), 1);
922    }
923
924    #[test]
925    fn test_tx_operation_key_count_batch_put() {
926        let op = batch_put_op(&[("a", b"1"), ("b", b"2"), ("c", b"3"), ("d", b"4")]);
927        assert_eq!(op.key_count(), 4);
928    }
929
930    // ── 12. drain_committed ───────────────────────────────────────────────────
931
932    #[test]
933    fn test_drain_committed_returns_and_clears() {
934        let mut log = StorageTransactionLog::new(10);
935        let id1 = log.begin(0);
936        log.commit(id1, 1).unwrap();
937        let id2 = log.begin(2);
938        log.commit(id2, 3).unwrap();
939        let drained = log.drain_committed();
940        assert_eq!(drained.len(), 2);
941        assert_eq!(log.committed_retained(), 0);
942    }
943
944    // ── 13. concurrent-ish interleaving ──────────────────────────────────────
945
946    #[test]
947    fn test_interleaved_transactions() {
948        let mut log = StorageTransactionLog::new(10);
949        let id_a = log.begin(0);
950        let id_b = log.begin(0);
951        log.append(id_a, put_op("a", b"va")).unwrap();
952        log.append(id_b, put_op("b", b"vb")).unwrap();
953        log.append(id_a, put_op("a2", b"va2")).unwrap();
954        log.commit(id_b, 1).unwrap();
955        log.rollback(id_a, 2).unwrap();
956        assert_eq!(log.committed_count_total(), 1);
957        assert_eq!(log.rolled_back_count_total(), 1);
958        assert_eq!(log.active_count(), 0);
959    }
960
961    #[test]
962    fn test_rollback_with_prev_values() {
963        let mut log = StorageTransactionLog::new(10);
964        let id = log.begin(0);
965        log.append(id, put_op_with_prev("k", b"new", b"old"))
966            .unwrap();
967        let ops = log.rollback(id, 1).unwrap();
968        assert_eq!(ops.len(), 1);
969        if let TxOperation::Put { prev_value, .. } = &ops[0] {
970            assert_eq!(prev_value.as_deref(), Some(b"old".as_slice()));
971        } else {
972            panic!("expected Put op");
973        }
974    }
975
976    // ── 14. edge cases ───────────────────────────────────────────────────────
977
978    #[test]
979    fn test_commit_does_not_affect_other_active_txs() {
980        let mut log = StorageTransactionLog::new(10);
981        let id_a = log.begin(0);
982        let id_b = log.begin(0);
983        log.commit(id_a, 1).unwrap();
984        assert_eq!(log.active_count(), 1);
985        assert!(log.get_transaction(id_b).is_some());
986    }
987
988    #[test]
989    fn test_large_batch_eviction_boundary() {
990        let mut log = StorageTransactionLog::new(5);
991        let ids: Vec<_> = (0..10u64).map(|t| log.begin(t)).collect();
992        for (i, &id) in ids.iter().enumerate() {
993            log.commit(id, i as u64 + 100).unwrap();
994        }
995        assert_eq!(log.committed_retained(), 5);
996        assert_eq!(log.committed_count_total(), 10);
997        // Only the last 5 should be retained (ids 6–10).
998        let retained = log.replay_committed(TransactionId(0));
999        let min_id = retained.iter().map(|tx| tx.id.0).min().unwrap_or(0);
1000        assert_eq!(min_id, 6);
1001    }
1002
1003    #[test]
1004    fn test_transaction_id_ordering() {
1005        assert!(TransactionId(1) < TransactionId(2));
1006        assert!(TransactionId(100) > TransactionId(50));
1007        assert_eq!(TransactionId(5), TransactionId(5));
1008    }
1009
1010    #[test]
1011    fn test_transaction_id_display() {
1012        let id = TransactionId(7);
1013        assert_eq!(format!("{id}"), "Tx(7)");
1014    }
1015
1016    #[test]
1017    fn test_tx_error_display() {
1018        assert_eq!(
1019            format!("{}", TxError::TransactionNotFound(3)),
1020            "transaction 3 not found"
1021        );
1022        assert_eq!(
1023            format!("{}", TxError::TransactionNotActive(5)),
1024            "transaction 5 is not active"
1025        );
1026        assert_eq!(
1027            format!("{}", TxError::TransactionAlreadyEnded(9)),
1028            "transaction 9 has already ended"
1029        );
1030    }
1031
1032    #[test]
1033    fn test_many_operations_in_single_tx() {
1034        let mut log = StorageTransactionLog::new(10);
1035        let id = log.begin(0);
1036        for i in 0u64..500 {
1037            log.append(id, put_op(&format!("key-{i}"), b"value"))
1038                .unwrap();
1039        }
1040        let tx = log.get_transaction(id).unwrap();
1041        assert_eq!(tx.operation_count(), 500);
1042        log.commit(id, 1).unwrap();
1043        let s = log.stats();
1044        assert_eq!(s.total_operations, 500);
1045    }
1046}