Skip to main content

nodedb_lite/nodedb/trait_impl/
document_batch.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Batch document + vector ingest for `NodeDbLite`.
4//!
5//! `document_put_with_vector_batch_impl` takes a slice of
6//! `(doc_collection, doc, vector_collection, id, embedding)` items and
7//! acquires the CRDT lock exactly **once** for the whole batch — that is the
8//! win over the single-item path, which takes and releases the lock per item.
9//!
10//! The batch does **not** collapse into one delta: `CrdtEngine::batch_write`
11//! emits one `PendingDelta` per CRDT row, tagged with that row's real
12//! collection and document ID. A delta spanning several rows (or several
13//! collections) is not independently applicable by a receiver, which commits
14//! per row and stores documents per collection. So a batch of N items emits
15//! N document deltas plus one vector-metadata delta per item that carries a
16//! non-empty embedding. See `CrdtEngine::batch_write` for the contract.
17//!
18//! Each document is a full-row upsert; each vector-metadata write is a field
19//! merge, so a vector attached to its own document's row keeps that
20//! document's fields. An id repeated within the batch, or already indexed,
21//! keeps one HNSW node: the latest vector.
22//!
23//! FTS indexing, bitemporal history writes, and HNSW inserts run after
24//! the CRDT lock is released — matching the ordering of the single-item path.
25
26use nodedb_types::document::Document;
27use nodedb_types::error::{NodeDbError, NodeDbResult};
28
29use crate::engine::crdt::{CrdtRowOp, CrdtRowWrite};
30use crate::engine::document::history::ops::{is_bitemporal, versioned_put};
31use crate::engine::vector::nodes::{encode_sidecar, upsert_node};
32use crate::engine::vector::resident::check_insert_widths;
33use crate::engine::vector::row::EMBEDDING_DIM_FIELD;
34use crate::nodedb::LockExt;
35use crate::nodedb::NodeDbLite;
36use crate::nodedb::convert::{document_to_msgpack, value_to_loro};
37use crate::runtime::now_millis_i64;
38use crate::storage::engine::StorageEngine;
39
40/// One item in a batch ingest call.
41pub struct BatchItem<'a> {
42    pub doc_collection: &'a str,
43    pub doc: Document,
44    pub vector_collection: &'a str,
45    pub id: &'a str,
46    pub embedding: Option<&'a [f32]>,
47}
48
49/// A list of CRDT fields for one upsert: borrowed field name → Loro value.
50type LoroFields<'a> = Vec<(&'a str, loro::LoroValue)>;
51
52/// A batch item resolved before the CRDT lock is taken:
53/// `(document id, document fields, vector-metadata fields)`.
54type ResolvedBatchItem<'a> = (String, LoroFields<'a>, LoroFields<'a>);
55
56impl<S: StorageEngine> NodeDbLite<S> {
57    /// Batch upsert of documents with optional embeddings.
58    ///
59    /// Acquires the CRDT lock once and runs every per-item Loro mutation
60    /// under that single hold. Per `CrdtEngine::batch_write`, each CRDT row
61    /// still exports its own delta — one per document, plus one per item with
62    /// a non-empty embedding for the vector-metadata row — because a delta
63    /// covering several rows or collections is not independently applicable by
64    /// the receiver.
65    ///
66    /// FTS indexing, bitemporal history, and HNSW inserts happen after the
67    /// lock is released, in the same relative order as the single-item path.
68    ///
69    /// Returns the list of document IDs written, in input order.
70    pub async fn document_put_with_vector_batch_impl(
71        &self,
72        items: &[BatchItem<'_>],
73    ) -> NodeDbResult<Vec<String>> {
74        let guard = self.fts_state.admit_mutation().await;
75        let result = async {
76            if items.is_empty() {
77                return Ok(Vec::new());
78            }
79
80            // Reject the whole batch up front under critical memory pressure, matching
81            // the single-item `document_put_impl` / `vector_insert_impl` guard. A batch
82            // can ingest many documents plus embeddings at once, so the early gate is
83            // even more important here than on the single-item path.
84            if self.governor.worst_engine_pressure() == nodedb_mem::PressureLevel::Emergency {
85                return Err(NodeDbError::storage(
86                    crate::error::LiteError::Backpressure {
87                        detail: "batch ingest rejected: memory governor is at Emergency pressure"
88                            .into(),
89                    },
90                ));
91            }
92
93            // Every embedding must fit its loaded index, or the first embedding
94            // bound for the same index when none is loaded. A refusal writes
95            // nothing: the check runs before the CRDT upsert and the durable rows.
96            let mut widths_by_index: std::collections::HashMap<&str, Vec<usize>> =
97                std::collections::HashMap::new();
98            for item in items {
99                if let Some(emb) = item.embedding
100                    && !emb.is_empty()
101                {
102                    widths_by_index
103                        .entry(item.vector_collection)
104                        .or_default()
105                        .push(emb.len());
106                }
107            }
108            for (index_key, widths) in &widths_by_index {
109                check_insert_widths(&self.vector_state, index_key, widths.iter().copied())
110                    .await
111                    .map_err(NodeDbError::from)?;
112            }
113
114            // A batch may write bitemporal collections: hold off any index build
115            // reading history until every item's history version is written.
116            let _index_build = self.hold_bitemporal_build(true).await;
117
118            // Pre-compute doc IDs and field vecs before taking the lock.
119            let mut resolved: Vec<ResolvedBatchItem<'_>> = Vec::with_capacity(items.len());
120
121            for item in items {
122                let doc_id = if item.doc.id.is_empty() {
123                    nodedb_types::id_gen::uuid_v7()
124                } else {
125                    item.doc.id.clone()
126                };
127
128                let doc_fields: Vec<(&str, loro::LoroValue)> = item
129                    .doc
130                    .fields
131                    .iter()
132                    .map(|(k, v)| (k.as_str(), value_to_loro(v)))
133                    .collect();
134
135                let vec_fields: Vec<(&str, loro::LoroValue)> = match item.embedding {
136                    Some(emb) if !emb.is_empty() => {
137                        vec![(EMBEDDING_DIM_FIELD, loro::LoroValue::I64(emb.len() as i64))]
138                    }
139                    _ => vec![],
140                };
141
142                resolved.push((doc_id, doc_fields, vec_fields));
143            }
144
145            // A written document is live again, even one deleted in history.
146            for (item, (doc_id, _, _)) in items.iter().zip(&resolved) {
147                self.query_engine
148                    .indexes
149                    .revive(item.doc_collection, doc_id);
150            }
151
152            // Build the ops slice for batch_write — one CRDT lock hold, one
153            // exported delta per row. Documents replace their row; vector
154            // metadata merges into its row.
155            {
156                let mut crdt = self.crdt.lock_or_recover();
157
158                let mut ops: Vec<CrdtRowOp<'_>> = Vec::with_capacity(items.len() * 2);
159                for (item, (doc_id, doc_fields, vec_fields)) in items.iter().zip(&resolved) {
160                    ops.push((
161                        CrdtRowWrite::Upsert,
162                        item.doc_collection,
163                        doc_id.as_str(),
164                        doc_fields.as_slice(),
165                    ));
166                    if !vec_fields.is_empty() {
167                        ops.push((
168                            CrdtRowWrite::SetFields,
169                            item.vector_collection,
170                            item.id,
171                            vec_fields.as_slice(),
172                        ));
173                    }
174                }
175
176                crdt.batch_write(&ops).map_err(NodeDbError::from)?;
177            }
178
179            // Post-lock work: bitemporal history + FTS + HNSW (matches single-item ordering).
180            let now_ms = now_millis_i64();
181
182            for (item, (doc_id, _, _)) in items.iter().zip(&resolved) {
183                if is_bitemporal(&*self.storage, item.doc_collection)
184                    .await
185                    .map_err(NodeDbError::storage)?
186                {
187                    let body = document_to_msgpack(&item.doc);
188                    versioned_put(
189                        &*self.storage,
190                        item.doc_collection,
191                        doc_id,
192                        &body,
193                        now_ms,
194                        None,
195                        None,
196                    )
197                    .await
198                    .map_err(NodeDbError::storage)?;
199                }
200                // Same durability contract as the single-document path: the
201                // vector is persisted in the same write that makes the document
202                // durable, so the in-memory HNSW below is a derived index rather
203                // than the only copy.
204                if let Some(embedding) = item.embedding
205                    && !embedding.is_empty()
206                {
207                    let op = crate::engine::vector::durable::put_op(
208                        item.vector_collection,
209                        item.id,
210                        embedding,
211                    );
212                    self.storage
213                        .batch_write(std::slice::from_ref(&op))
214                        .await
215                        .map_err(NodeDbError::storage)?;
216                }
217
218                self.index_document_text(item.doc_collection, doc_id, &item.doc.fields)?;
219                self.index_document_sparse(item.doc_collection, doc_id, &item.doc.fields);
220
221                if let Some(embedding) = item.embedding
222                    && !embedding.is_empty()
223                {
224                    // Replaces the node the id held before, including one bound
225                    // earlier in this batch, so the id stays one node.
226                    let internal_id = upsert_node(
227                        &self.vector_state,
228                        item.vector_collection,
229                        item.id,
230                        embedding,
231                    )
232                    .await
233                    .map_err(NodeDbError::from)?;
234                    encode_sidecar(
235                        &self.vector_state,
236                        item.vector_collection,
237                        internal_id,
238                        embedding,
239                    )
240                    .map_err(|e| NodeDbError::bad_request(e.to_string()))?;
241
242                    #[cfg(not(target_arch = "wasm32"))]
243                    if let Some(q) = &self.vector_outbound {
244                        crate::sync::reconcile_outbound_enqueue(
245                            q.enqueue_insert(
246                                item.vector_collection,
247                                item.id,
248                                embedding.to_vec(),
249                                embedding.len(),
250                                "",
251                            )
252                            .await,
253                            "vector insert (batch)",
254                            item.vector_collection,
255                            item.id,
256                        )
257                        .map_err(nodedb_types::error::NodeDbError::storage)?;
258                    }
259                }
260            }
261
262            if items
263                .iter()
264                .any(|it| it.embedding.is_some_and(|e| !e.is_empty()))
265            {
266                self.update_memory_stats();
267            }
268
269            Ok(resolved.into_iter().map(|(id, _, _)| id).collect())
270        }
271        .await;
272        guard.finish(result)
273    }
274}