Skip to main content

nodedb_lite/nodedb/collection/
transaction.rs

1//! Atomic transactions and conflict resolution policies.
2//!
3//! Loro transactions are operation bundling (not ACID rollback). Our
4//! approach: validate all operations upfront, then apply as a single
5//! `batch_upsert` + batch delete. One Loro delta for the entire
6//! transaction. If validation fails, nothing is applied.
7
8use std::collections::HashMap;
9
10use nodedb_types::error::{NodeDbError, NodeDbResult};
11use nodedb_types::value::Value;
12
13use super::super::convert::value_to_loro;
14use super::super::{LockExt, NodeDbLite};
15use crate::engine::crdt::engine::{CrdtBatchOp, CrdtField};
16use crate::storage::engine::StorageEngine;
17
18/// A single operation in a transaction batch.
19#[derive(Debug, Clone)]
20pub enum TransactionOp {
21    Put {
22        collection: String,
23        doc_id: String,
24        fields: HashMap<String, Value>,
25    },
26    Delete {
27        collection: String,
28        doc_id: String,
29    },
30}
31
32impl<S: StorageEngine> NodeDbLite<S> {
33    /// Execute a batch of operations atomically.
34    ///
35    /// **Atomicity model**: all operations are validated upfront. If any
36    /// validation fails, none are applied. Once validation passes, all
37    /// operations are applied under a single CRDT lock hold. Each row still
38    /// exports its own delta (see `CrdtEngine::batch_upsert`) — a delta
39    /// spanning rows or collections is not independently applicable by the
40    /// receiver.
41    ///
42    /// This is NOT ACID rollback — Loro doesn't support undo. Instead,
43    /// we validate-then-apply: no partial commits are possible because
44    /// we only start writing after all checks pass.
45    ///
46    /// Returns the number of operations applied.
47    pub fn transaction(&self, ops: &[TransactionOp]) -> NodeDbResult<u64> {
48        let guard = self
49            .fts_state
50            .try_admit_mutation("transaction")
51            .map_err(NodeDbError::from)?;
52        let result = (|| {
53            if ops.is_empty() {
54                return Ok(0);
55            }
56
57            // Phase 1: Validate all operations.
58            // - Put: validate doc_id is non-empty, fields are non-empty.
59            // - Delete: validate doc_id is non-empty.
60            for (i, op) in ops.iter().enumerate() {
61                match op {
62                    TransactionOp::Put {
63                        collection,
64                        doc_id,
65                        fields,
66                    } => {
67                        if doc_id.is_empty() {
68                            return Err(NodeDbError::bad_request(format!(
69                                "transaction op {i}: Put requires non-empty doc_id"
70                            )));
71                        }
72                        if collection.is_empty() {
73                            return Err(NodeDbError::bad_request(format!(
74                                "transaction op {i}: Put requires non-empty collection"
75                            )));
76                        }
77                        if fields.is_empty() {
78                            return Err(NodeDbError::bad_request(format!(
79                                "transaction op {i}: Put requires at least one field"
80                            )));
81                        }
82                    }
83                    TransactionOp::Delete { collection, doc_id } => {
84                        if doc_id.is_empty() || collection.is_empty() {
85                            return Err(NodeDbError::bad_request(format!(
86                                "transaction op {i}: Delete requires non-empty collection and doc_id"
87                            )));
88                        }
89                    }
90                }
91            }
92
93            // Phase 2: Separate puts and deletes.
94            let mut put_ops: Vec<(&str, &str, Vec<CrdtField<'_>>)> = Vec::new();
95            let mut delete_ops: Vec<(&str, &str)> = Vec::new();
96
97            for op in ops {
98                match op {
99                    TransactionOp::Put {
100                        collection,
101                        doc_id,
102                        fields,
103                    } => {
104                        let loro_fields: Vec<CrdtField<'_>> = fields
105                            .iter()
106                            .map(|(k, v)| (k.as_str(), value_to_loro(v)))
107                            .collect();
108                        put_ops.push((collection.as_str(), doc_id.as_str(), loro_fields));
109                    }
110                    TransactionOp::Delete { collection, doc_id } => {
111                        delete_ops.push((collection.as_str(), doc_id.as_str()));
112                    }
113                }
114            }
115
116            // Phase 3: Apply all operations under one CRDT lock hold.
117            // One delta export per row, not one for the transaction.
118            let mut crdt = self.crdt.lock_or_recover();
119
120            // Build batch ops with borrowed field slices.
121            let batch_refs: Vec<CrdtBatchOp<'_>> = put_ops
122                .iter()
123                .map(|(coll, id, fields)| (*coll, *id, fields.as_slice()))
124                .collect();
125
126            if !batch_refs.is_empty() {
127                crdt.batch_upsert(&batch_refs).map_err(NodeDbError::from)?;
128            }
129
130            // Deletes are applied individually but within the same lock hold;
131            // each one exports its own delta, like every upsert above.
132            for &(collection, doc_id) in &delete_ops {
133                crdt.delete(collection, doc_id)
134                    .map_err(NodeDbError::storage)?;
135            }
136
137            let count = (put_ops.len() + delete_ops.len()) as u64;
138            drop(crdt);
139
140            // Phase 4: Update text indices for all affected documents.
141            for (collection, doc_id, _) in &put_ops {
142                let crdt = self.crdt.lock_or_recover();
143                if let Some(loro_val) = crdt.read(collection, doc_id) {
144                    let doc = crate::nodedb::convert::loro_value_to_document(doc_id, &loro_val);
145                    drop(crdt);
146                    self.index_document_text(collection, doc_id, &doc.fields)?;
147                    self.index_document_sparse(collection, doc_id, &doc.fields);
148                }
149            }
150            for &(collection, doc_id) in &delete_ops {
151                self.remove_document_text(collection, doc_id)?;
152                self.remove_document_sparse(collection, doc_id);
153            }
154
155            Ok(count)
156        })();
157        guard.finish(result)
158    }
159
160    /// Set conflict resolution policy for a collection.
161    ///
162    /// Policies are evaluated on sync when Origin rejects a delta.
163    /// Available policies from `nodedb-crdt::PolicyRegistry`:
164    /// - LastWriterWins (default)
165    /// - RenameSuffix
166    /// - CascadeDefer
167    /// - EscalateToDlq
168    pub fn set_conflict_policy(&self, collection: &str, policy: nodedb_crdt::CollectionPolicy) {
169        let mut crdt = self.crdt.lock_or_recover();
170        crdt.set_policy(collection, policy);
171    }
172}