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}