Skip to main content

nodedb_lite/nodedb/collection/
bulk.rs

1//! Bulk update and delete by predicate (ScanFilter-based).
2//!
3//! Holds CRDT lock across scan+write to prevent concurrent modification
4//! between the filter evaluation and the mutation application.
5
6use std::collections::HashMap;
7
8use nodedb_types::error::{NodeDbError, NodeDbResult};
9use nodedb_types::value::Value;
10
11use super::super::convert::value_to_loro;
12use super::super::{LockExt, NodeDbLite};
13use crate::storage::engine::StorageEngine;
14
15impl<S: StorageEngine> NodeDbLite<S> {
16    /// Bulk update documents matching a predicate.
17    ///
18    /// Scans all documents, evaluates `ScanFilter` predicates, and applies
19    /// `updates` to matching documents — all under a single CRDT lock to
20    /// prevent concurrent writes from being lost between scan and update.
21    ///
22    /// Returns the number of documents updated.
23    pub fn bulk_update(
24        &self,
25        collection: &str,
26        filters: &[nodedb_query::ScanFilter],
27        updates: &HashMap<String, Value>,
28    ) -> NodeDbResult<u64> {
29        let guard = self
30            .fts_state
31            .try_admit_mutation("bulk_update")
32            .map_err(NodeDbError::from)?;
33        let result = (|| {
34            // Single lock for scan + write: no gap for concurrent modifications.
35            let mut crdt = self.crdt.lock_or_recover();
36            let ids = crdt.list_ids(collection);
37
38            let mut matching_ids = Vec::new();
39            for id in ids {
40                if let Some(loro_val) = crdt.read(collection, &id) {
41                    let doc = crate::nodedb::convert::loro_value_to_document(&id, &loro_val);
42                    let json = serde_json::to_value(&doc.fields).unwrap_or_default();
43                    let msgpack = nodedb_types::json_msgpack::json_to_msgpack_or_empty(&json);
44                    if filters.is_empty()
45                        || nodedb_query::ScanFilter::all_match_binary(filters, &msgpack)
46                            .map_err(|_| NodeDbError::division_by_zero())?
47                    {
48                        matching_ids.push(id);
49                    }
50                }
51            }
52
53            let update_fields: Vec<(&str, loro::LoroValue)> = updates
54                .iter()
55                .map(|(k, v)| (k.as_str(), value_to_loro(v)))
56                .collect();
57
58            let mut count = 0u64;
59            for id in &matching_ids {
60                // A merge: fields the update does not name keep their values.
61                crdt.set_fields(collection, id, &update_fields)
62                    .map_err(NodeDbError::from)?;
63                count += 1;
64            }
65            drop(crdt);
66
67            // Update text index outside the CRDT lock (text index has its own lock).
68            for id in &matching_ids {
69                let crdt = self.crdt.lock_or_recover();
70                if let Some(loro_val) = crdt.read(collection, id) {
71                    let doc = crate::nodedb::convert::loro_value_to_document(id, &loro_val);
72                    drop(crdt);
73                    self.index_document_text(collection, id, &doc.fields)?;
74                    self.index_document_sparse(collection, id, &doc.fields);
75                }
76            }
77
78            Ok(count)
79        })();
80        guard.finish(result)
81    }
82
83    /// Bulk delete documents matching a predicate.
84    ///
85    /// Same single-lock pattern as `bulk_update`.
86    /// Returns the number of documents deleted.
87    pub fn bulk_delete(
88        &self,
89        collection: &str,
90        filters: &[nodedb_query::ScanFilter],
91    ) -> NodeDbResult<u64> {
92        let guard = self
93            .fts_state
94            .try_admit_mutation("bulk_delete")
95            .map_err(NodeDbError::from)?;
96        let result = (|| {
97            let mut crdt = self.crdt.lock_or_recover();
98            let ids = crdt.list_ids(collection);
99
100            let mut matching_ids = Vec::new();
101            for id in ids {
102                if let Some(loro_val) = crdt.read(collection, &id) {
103                    let doc = crate::nodedb::convert::loro_value_to_document(&id, &loro_val);
104                    let json = serde_json::to_value(&doc.fields).unwrap_or_default();
105                    let msgpack = nodedb_types::json_msgpack::json_to_msgpack_or_empty(&json);
106                    if filters.is_empty()
107                        || nodedb_query::ScanFilter::all_match_binary(filters, &msgpack)
108                            .map_err(|_| NodeDbError::division_by_zero())?
109                    {
110                        matching_ids.push(id);
111                    }
112                }
113            }
114
115            let mut count = 0u64;
116            for id in &matching_ids {
117                crdt.delete(collection, id).map_err(NodeDbError::storage)?;
118                count += 1;
119            }
120            drop(crdt);
121
122            for id in &matching_ids {
123                self.remove_document_text(collection, id)?;
124                self.remove_document_sparse(collection, id);
125            }
126
127            Ok(count)
128        })();
129        guard.finish(result)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use crate::nodedb::LockExt;
136    use crate::{LiteConfig, NodeDbLite, PagedbStorageMem};
137    use nodedb_types::value::Value;
138    use std::collections::HashMap;
139
140    #[tokio::test]
141    async fn busy_bulk_update_preserves_source_and_checkpoint_trust() {
142        let storage = PagedbStorageMem::open_in_memory().await.unwrap();
143        let db = NodeDbLite::open_with_config(
144            storage,
145            LiteConfig {
146                auto_flush_ms: 0,
147                sync_enabled: false,
148                ..LiteConfig::default()
149            },
150        )
151        .await
152        .unwrap();
153        db.crdt
154            .lock_or_recover()
155            .upsert("docs", "a", &[("value", loro::LoroValue::I64(1))])
156            .unwrap();
157        db.fts_state.mark_checkpoint_trusted();
158        let permit = db.fts_state.admit_exclusive().await;
159        let updates = HashMap::from([("value".into(), Value::Integer(2))]);
160        let result = db.bulk_update("docs", &[], &updates);
161        assert!(result.is_err());
162        assert_eq!(
163            db.crdt.lock_or_recover().read_field("docs", "a", "value"),
164            Some(loro::LoroValue::I64(1))
165        );
166        assert!(db.fts_state.checkpoint_trusted());
167        drop(permit);
168        assert_eq!(db.bulk_update("docs", &[], &updates).unwrap(), 1);
169    }
170}