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}