Skip to main content

nodedb_lite/nodedb/
batch.rs

1//! Batch operations and memory eviction for NodeDbLite.
2
3use nodedb_types::Namespace;
4use nodedb_types::document::Document;
5use nodedb_types::error::{NodeDbError, NodeDbResult};
6use nodedb_types::id::{EdgeId, NodeId};
7
8use crate::engine::crdt::engine::CrdtBatchOp;
9use crate::engine::graph::edge::{
10    edge_crdt_collection, edge_crdt_fields, edge_history_value, edge_id_for,
11};
12use crate::engine::graph::history;
13use crate::engine::graph::index::CsrIndex;
14use crate::engine::vector::nodes::{bind_node, encode_sidecar};
15use crate::engine::vector::resident::{check_insert_widths, lock_resident_or_create};
16use crate::engine::vector::row::EMBEDDING_DIM_FIELD;
17use crate::runtime::now_millis_i64;
18
19use super::{LockExt, NodeDbLite};
20use crate::storage::engine::StorageEngine;
21
22impl<S: StorageEngine> NodeDbLite<S> {
23    /// Batch insert vectors — O(1) CRDT delta export instead of O(N).
24    ///
25    /// Use this for bulk loading (cold-start hydration, benchmark setup, imports).
26    /// Each vector is inserted into HNSW and tracked in the ID map. An id
27    /// repeated in the batch, or already indexed, keeps one node: the latest
28    /// vector. Each id's embedding dimension merges into its CRDT row, whose
29    /// other fields are kept.
30    pub async fn batch_vector_insert(
31        &self,
32        collection: &str,
33        vectors: &[(&str, &[f32])],
34    ) -> NodeDbResult<()> {
35        if vectors.is_empty() {
36            return Ok(());
37        }
38
39        if self.governor.worst_engine_pressure() == nodedb_mem::PressureLevel::Emergency {
40            return Err(NodeDbError::storage(
41                crate::error::LiteError::Backpressure {
42                    detail:
43                        "batch vector insert rejected: memory governor is at Emergency pressure"
44                            .into(),
45                },
46            ));
47        }
48
49        let dim = vectors[0].1.len();
50
51        // Every vector must fit the collection's index, loaded back if it was
52        // evicted, or the batch's first vector when there is none; a refusal
53        // writes nothing.
54        check_insert_widths(
55            &self.vector_state,
56            collection,
57            vectors.iter().map(|(_, e)| e.len()),
58        )
59        .await
60        .map_err(NodeDbError::from)?;
61
62        // Durable rows first, in ONE batch write. They are the source of truth
63        // the in-memory index and the pagedb segment are both derived from: a
64        // flush that finds no durable row for a collection writes no segment for
65        // it, so skipping this would make a bulk load non-durable.
66        {
67            let ops: Vec<_> = vectors
68                .iter()
69                .filter(|(_, embedding)| !embedding.is_empty())
70                .map(|(id, embedding)| {
71                    crate::engine::vector::durable::put_op(collection, id, embedding)
72                })
73                .collect();
74            if !ops.is_empty() {
75                self.storage
76                    .batch_write(&ops)
77                    .await
78                    .map_err(NodeDbError::storage)?;
79            }
80        }
81
82        // Each id's final node, for the codec sidecar: a node an id held
83        // earlier in the batch is already tombstoned.
84        let mut bound: std::collections::HashMap<&str, (u32, &[f32])> =
85            std::collections::HashMap::with_capacity(vectors.len());
86        {
87            let mut resident = lock_resident_or_create(&self.vector_state, collection, dim)
88                .await
89                .map_err(NodeDbError::from)?;
90            let index = resident.index();
91            for &(id, embedding) in vectors {
92                let node = bind_node(
93                    &self.vector_state,
94                    index,
95                    collection,
96                    id,
97                    embedding.to_vec(),
98                )
99                .map_err(NodeDbError::from)?;
100                bound.insert(id, (node, embedding));
101            }
102        }
103        // Encoded after the index lock is released: installing a sidecar
104        // trains on the index.
105        for (node, embedding) in bound.into_values() {
106            encode_sidecar(&self.vector_state, collection, node, embedding)
107                .map_err(|e| NodeDbError::bad_request(e.to_string()))?;
108        }
109
110        {
111            let mut crdt = self.crdt.lock_or_recover();
112
113            use crate::engine::crdt::{CrdtField, CrdtRowOp, CrdtRowWrite};
114
115            let fields: Vec<Vec<CrdtField<'_>>> = vectors
116                .iter()
117                .map(|&(_, emb)| {
118                    vec![(EMBEDDING_DIM_FIELD, loro::LoroValue::I64(emb.len() as i64))]
119                })
120                .collect();
121
122            // A merge: each vector attaches to its row and keeps its fields.
123            let ops: Vec<CrdtRowOp<'_>> = vectors
124                .iter()
125                .zip(fields.iter())
126                .map(|(&(id, _), f)| (CrdtRowWrite::SetFields, collection, id, f.as_slice()))
127                .collect();
128
129            crdt.batch_write(&ops).map_err(NodeDbError::from)?;
130        }
131
132        self.update_memory_stats();
133        Ok(())
134    }
135
136    /// Batch insert graph edges into a named collection — O(1) CRDT delta
137    /// export instead of O(N). Edges are isolated to `collection`.
138    ///
139    /// Mirrors `graph_insert_edge`'s argument order (`from, to, edge_type,
140    /// properties`) and shares its edge-preparation logic (`EdgeId`
141    /// allocation, CRDT field list): a batch of one edge stores the same
142    /// `EdgeId` key and the same fields a single `graph_insert_edge` call
143    /// would, so `graph_delete_edge` can remove either durably and rebuild
144    /// never resurrects a deleted batch-inserted edge.
145    ///
146    /// Bitemporal history is recorded per edge when `collection` is
147    /// bitemporal, and memory stats are updated once at the end. Returns
148    /// the allocated `EdgeId` for each input edge, in order.
149    pub async fn batch_graph_insert_edges(
150        &self,
151        collection: &str,
152        edges: &[(NodeId, NodeId, &str, Option<Document>)],
153    ) -> NodeDbResult<Vec<EdgeId>> {
154        if edges.is_empty() {
155            return Ok(Vec::new());
156        }
157
158        if self.governor.worst_engine_pressure() == nodedb_mem::PressureLevel::Emergency {
159            return Err(NodeDbError::storage(
160                crate::error::LiteError::Backpressure {
161                    detail:
162                        "batch graph edge insert rejected: memory governor is at Emergency pressure"
163                            .into(),
164                },
165            ));
166        }
167
168        // Allocate every EdgeId (and validate every label) before mutating
169        // anything: a bad label refuses the whole batch, not just its tail.
170        let edge_ids: Vec<EdgeId> = edges
171            .iter()
172            .map(|(from, to, edge_type, _)| edge_id_for(from, to, edge_type))
173            .collect::<NodeDbResult<Vec<_>>>()?;
174
175        {
176            let memory = self.memory_for(nodedb_mem::EngineId::Graph);
177            let mut csr_map = self.csr.lock_or_recover();
178            let csr = csr_map
179                .entry(collection.to_string())
180                .or_insert_with(|| CsrIndex::new(memory));
181            for (from, to, edge_type, _) in edges {
182                let _ = csr.add_edge(from.as_str(), edge_type, to.as_str());
183            }
184        }
185
186        let edge_coll = edge_crdt_collection(collection);
187        let edge_keys: Vec<String> = edge_ids.iter().map(|id| format!("{id}")).collect();
188        let field_lists: Vec<Vec<(&str, loro::LoroValue)>> = edges
189            .iter()
190            .map(|(from, to, edge_type, properties)| {
191                edge_crdt_fields(from, to, edge_type, properties)
192            })
193            .collect();
194
195        {
196            let mut crdt = self.crdt.lock_or_recover();
197            let ops: Vec<CrdtBatchOp<'_>> = edge_keys
198                .iter()
199                .zip(field_lists.iter())
200                .map(|(key, fields)| (edge_coll.as_str(), key.as_str(), fields.as_slice()))
201                .collect();
202            crdt.batch_upsert(&ops).map_err(NodeDbError::storage)?;
203        }
204
205        // Record each edge's birth in the bitemporal history table if the
206        // collection has bitemporal tracking enabled.
207        let bitemporal = history::is_bitemporal(self.storage.as_ref(), collection)
208            .await
209            .unwrap_or(false);
210        if bitemporal {
211            let system_from_ms = now_millis_i64();
212            for (i, (from, to, edge_type, properties)) in edges.iter().enumerate() {
213                let props_value = edge_history_value(from, to, edge_type, properties);
214                let _ = history::record_edge_insert(
215                    self.storage.as_ref(),
216                    collection,
217                    &edge_keys[i],
218                    &props_value,
219                    system_from_ms,
220                )
221                .await;
222            }
223        }
224
225        self.update_memory_stats();
226        Ok(edge_ids)
227    }
228
229    /// Compact all per-collection CSR graph indices (merge buffer into dense arrays).
230    pub fn compact_graph(&self) -> NodeDbResult<()> {
231        let mut csr_map = self.csr.lock_or_recover();
232        for (name, csr) in csr_map.iter_mut() {
233            csr.compact().map_err(|e| {
234                NodeDbError::storage(format!("graph csr compact failed for '{name}': {e}"))
235            })?;
236        }
237        Ok(())
238    }
239
240    /// Evict HNSW collections to reduce memory usage.
241    ///
242    /// Persists each evicted collection to storage first, then drops
243    /// it from memory. Collections are evicted smallest-first.
244    pub async fn evict_collections(&self, max_to_evict: usize) -> NodeDbResult<usize> {
245        let mut evicted = 0;
246
247        let candidates: Vec<(String, usize)> = {
248            let indices = self.vector_state.hnsw_indices.lock_or_recover();
249            let mut sorted: Vec<(String, usize)> = indices
250                .iter()
251                .map(|(name, idx)| (name.clone(), idx.len()))
252                .collect();
253            sorted.sort_by_key(|(_, size)| *size);
254            sorted
255        };
256
257        // Check once whether the pagedb segment path is available.
258        #[cfg(not(target_arch = "wasm32"))]
259        let seg_ext = self.storage.as_vector_segment_ext();
260
261        for (name, _) in candidates.into_iter().take(max_to_evict) {
262            // Snapshot checkpoint while holding the lock.
263            // On native with segment support: graph-only bytes, and the segment
264            // payload is read from the DURABLE vectors after the lock is
265            // released — never from the in-memory index, which carries empty
266            // vector slots whenever it was itself restored from a graph-only
267            // checkpoint.
268            // Otherwise: full checkpoint blob (WASM and non-pagedb native backends).
269            //
270            // `snapshot` is the index's (node count, tombstone count) at the
271            // checkpoint. A write between the checkpoint and the removal
272            // changes it, and the index then stays resident: dropping it would
273            // lose that write from the stored copy.
274            #[cfg(not(target_arch = "wasm32"))]
275            let (blob, segment_sources, snapshot) = {
276                let indices = self.vector_state.hnsw_indices.lock_or_recover();
277                match indices.get(&name) {
278                    Some(idx) => {
279                        let snapshot = (idx.len(), idx.tombstone_count());
280                        if seg_ext.is_some() {
281                            let graph_bytes = idx.graph_checkpoint_to_bytes().map_err(|e| {
282                                NodeDbError::serialization("hnsw-graph-checkpoint", e)
283                            })?;
284                            // Segment slots follow node-id order; see
285                            // `engine::vector::segment`.
286                            let id_map = self.vector_state.vector_id_map.lock_or_recover();
287                            let sources = crate::engine::vector::segment::node_sources(
288                                idx,
289                                id_map.index(&name),
290                            );
291                            (graph_bytes, sources, snapshot)
292                        } else {
293                            let blob = idx
294                                .checkpoint_to_bytes()
295                                .map_err(|e| NodeDbError::serialization("hnsw-checkpoint", e))?;
296                            (blob, None, snapshot)
297                        }
298                    }
299                    None => continue,
300                }
301            };
302            #[cfg(target_arch = "wasm32")]
303            let (blob, snapshot) = {
304                let indices = self.vector_state.hnsw_indices.lock_or_recover();
305                match indices.get(&name) {
306                    Some(idx) => (
307                        idx.checkpoint_to_bytes()
308                            .map_err(|e| NodeDbError::serialization("hnsw-checkpoint", e))?,
309                        (idx.len(), idx.tombstone_count()),
310                    ),
311                    None => continue,
312                }
313            };
314
315            let key = format!("hnsw:{name}");
316            self.storage
317                .put(
318                    Namespace::Vector,
319                    key.as_bytes(),
320                    &crate::storage::checksum::wrap(&blob),
321                )
322                .await
323                .map_err(NodeDbError::storage)?;
324
325            // Write vector segment on native targets when segment ext is available.
326            #[cfg(not(target_arch = "wasm32"))]
327            if let Some(sources) = segment_sources
328                && let Some(ext) = seg_ext
329            {
330                match crate::engine::vector::segment::segment_payload(
331                    &*self.storage,
332                    &name,
333                    sources,
334                )
335                .await
336                {
337                    Ok(Some((dim, vectors, surrogates))) => {
338                        if let Err(e) = ext
339                            .write_vector_segment(&name, dim, &vectors, &surrogates)
340                            .await
341                        {
342                            tracing::error!(
343                                collection = %name,
344                                error = %e,
345                                "HNSW vector segment write failed during eviction; \
346                                 graph blob is persisted but vectors may be lost on cold restart"
347                            );
348                        }
349                    }
350                    // No durable vectors: leave any existing segment untouched.
351                    // Replacing it with an empty one is the corruption this avoids.
352                    Ok(None) => {}
353                    Err(e) => tracing::error!(
354                        collection = %name,
355                        error = %e,
356                        "reading durable vectors for the eviction segment write failed; \
357                         leaving the existing segment in place"
358                    ),
359                }
360            }
361
362            // Mark and drop under one lock, so a loader never sees the index
363            // gone without the mark. The stored checkpoint is valid, so an
364            // earlier unloadable verdict no longer holds.
365            {
366                let mut indices = self.vector_state.hnsw_indices.lock_or_recover();
367                let unchanged = indices
368                    .get(&name)
369                    .is_some_and(|idx| (idx.len(), idx.tombstone_count()) == snapshot);
370                if !unchanged {
371                    tracing::debug!(
372                        collection = %name,
373                        "HNSW collection changed during eviction; kept in memory"
374                    );
375                    continue;
376                }
377                self.vector_state
378                    .evicted
379                    .lock_or_recover()
380                    .insert(name.clone());
381                self.vector_state.unloadable.lock_or_recover().remove(&name);
382                indices.remove(&name);
383            }
384
385            tracing::info!(collection = %name, "HNSW collection evicted from memory");
386            evicted += 1;
387        }
388
389        self.update_memory_stats();
390        Ok(evicted)
391    }
392
393    /// Check memory pressure and evict if needed.
394    ///
395    /// Matches the old thresholds: nodedb_mem's Critical (85-95%) is where
396    /// Lite's own Warning used to start, and Emergency (>95%) is where
397    /// Lite's own Critical used to start. The new Warning tier (70-85%) is
398    /// below both old thresholds, so it evicts nothing, same as Normal.
399    pub async fn check_and_evict(&self) -> NodeDbResult<usize> {
400        use nodedb_mem::PressureLevel;
401
402        self.update_memory_stats();
403        match self.governor.worst_engine_pressure() {
404            PressureLevel::Emergency => self.evict_collections(2).await,
405            PressureLevel::Critical => self.evict_collections(1).await,
406            PressureLevel::Warning => Ok(0),
407            PressureLevel::Normal => Ok(0),
408        }
409    }
410}