use nodedb_types::document::Document;
use nodedb_types::error::{NodeDbError, NodeDbResult};
use crate::engine::crdt::{CrdtRowOp, CrdtRowWrite};
use crate::engine::document::history::ops::{is_bitemporal, versioned_put};
use crate::engine::vector::nodes::{encode_sidecar, upsert_node};
use crate::engine::vector::resident::check_insert_widths;
use crate::engine::vector::row::EMBEDDING_DIM_FIELD;
use crate::nodedb::LockExt;
use crate::nodedb::NodeDbLite;
use crate::nodedb::convert::{document_to_msgpack, value_to_loro};
use crate::runtime::now_millis_i64;
use crate::storage::engine::StorageEngine;
pub struct BatchItem<'a> {
pub doc_collection: &'a str,
pub doc: Document,
pub vector_collection: &'a str,
pub id: &'a str,
pub embedding: Option<&'a [f32]>,
}
type LoroFields<'a> = Vec<(&'a str, loro::LoroValue)>;
type ResolvedBatchItem<'a> = (String, LoroFields<'a>, LoroFields<'a>);
impl<S: StorageEngine> NodeDbLite<S> {
pub async fn document_put_with_vector_batch_impl(
&self,
items: &[BatchItem<'_>],
) -> NodeDbResult<Vec<String>> {
let guard = self.fts_state.admit_mutation().await;
let result = async {
if items.is_empty() {
return Ok(Vec::new());
}
if self.governor.worst_engine_pressure() == nodedb_mem::PressureLevel::Emergency {
return Err(NodeDbError::storage(
crate::error::LiteError::Backpressure {
detail: "batch ingest rejected: memory governor is at Emergency pressure"
.into(),
},
));
}
let mut widths_by_index: std::collections::HashMap<&str, Vec<usize>> =
std::collections::HashMap::new();
for item in items {
if let Some(emb) = item.embedding
&& !emb.is_empty()
{
widths_by_index
.entry(item.vector_collection)
.or_default()
.push(emb.len());
}
}
for (index_key, widths) in &widths_by_index {
check_insert_widths(&self.vector_state, index_key, widths.iter().copied())
.await
.map_err(NodeDbError::from)?;
}
let _index_build = self.hold_bitemporal_build(true).await;
let mut resolved: Vec<ResolvedBatchItem<'_>> = Vec::with_capacity(items.len());
for item in items {
let doc_id = if item.doc.id.is_empty() {
nodedb_types::id_gen::uuid_v7()
} else {
item.doc.id.clone()
};
let doc_fields: Vec<(&str, loro::LoroValue)> = item
.doc
.fields
.iter()
.map(|(k, v)| (k.as_str(), value_to_loro(v)))
.collect();
let vec_fields: Vec<(&str, loro::LoroValue)> = match item.embedding {
Some(emb) if !emb.is_empty() => {
vec![(EMBEDDING_DIM_FIELD, loro::LoroValue::I64(emb.len() as i64))]
}
_ => vec![],
};
resolved.push((doc_id, doc_fields, vec_fields));
}
for (item, (doc_id, _, _)) in items.iter().zip(&resolved) {
self.query_engine
.indexes
.revive(item.doc_collection, doc_id);
}
{
let mut crdt = self.crdt.lock_or_recover();
let mut ops: Vec<CrdtRowOp<'_>> = Vec::with_capacity(items.len() * 2);
for (item, (doc_id, doc_fields, vec_fields)) in items.iter().zip(&resolved) {
ops.push((
CrdtRowWrite::Upsert,
item.doc_collection,
doc_id.as_str(),
doc_fields.as_slice(),
));
if !vec_fields.is_empty() {
ops.push((
CrdtRowWrite::SetFields,
item.vector_collection,
item.id,
vec_fields.as_slice(),
));
}
}
crdt.batch_write(&ops).map_err(NodeDbError::from)?;
}
let now_ms = now_millis_i64();
for (item, (doc_id, _, _)) in items.iter().zip(&resolved) {
if is_bitemporal(&*self.storage, item.doc_collection)
.await
.map_err(NodeDbError::storage)?
{
let body = document_to_msgpack(&item.doc);
versioned_put(
&*self.storage,
item.doc_collection,
doc_id,
&body,
now_ms,
None,
None,
)
.await
.map_err(NodeDbError::storage)?;
}
if let Some(embedding) = item.embedding
&& !embedding.is_empty()
{
let op = crate::engine::vector::durable::put_op(
item.vector_collection,
item.id,
embedding,
);
self.storage
.batch_write(std::slice::from_ref(&op))
.await
.map_err(NodeDbError::storage)?;
}
self.index_document_text(item.doc_collection, doc_id, &item.doc.fields)?;
self.index_document_sparse(item.doc_collection, doc_id, &item.doc.fields);
if let Some(embedding) = item.embedding
&& !embedding.is_empty()
{
let internal_id = upsert_node(
&self.vector_state,
item.vector_collection,
item.id,
embedding,
)
.await
.map_err(NodeDbError::from)?;
encode_sidecar(
&self.vector_state,
item.vector_collection,
internal_id,
embedding,
)
.map_err(|e| NodeDbError::bad_request(e.to_string()))?;
#[cfg(not(target_arch = "wasm32"))]
if let Some(q) = &self.vector_outbound {
crate::sync::reconcile_outbound_enqueue(
q.enqueue_insert(
item.vector_collection,
item.id,
embedding.to_vec(),
embedding.len(),
"",
)
.await,
"vector insert (batch)",
item.vector_collection,
item.id,
)
.map_err(nodedb_types::error::NodeDbError::storage)?;
}
}
}
if items
.iter()
.any(|it| it.embedding.is_some_and(|e| !e.is_empty()))
{
self.update_memory_stats();
}
Ok(resolved.into_iter().map(|(id, _, _)| id).collect())
}
.await;
guard.finish(result)
}
}