mirror of
https://github.com/perstarkse/minne.git
synced 2026-07-10 06:42:43 +02:00
3aca33569d
Entity enrichment now uses embed_batch like chunks; the unused entity_embedding_concurrency knob is removed and ingest retry paths gain test coverage.
409 lines
13 KiB
Rust
409 lines
13 KiB
Rust
//! Atomic persistence for ingested artifacts.
|
|
//!
|
|
//! All rows for one ingestion task are written inside a single `SurrealDB` transaction:
|
|
//! clear any prior rows for the task's `source_id`, then insert the new snapshot.
|
|
//! `SurrealDB` does not cap transaction row count; request payload size is the practical
|
|
//! limit (~4 MiB gRPC on `TiKV`). Typical single-document ingests fit comfortably.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use common::{
|
|
error::AppError,
|
|
storage::{
|
|
db::SurrealDbClient,
|
|
types::{
|
|
knowledge_entity::KnowledgeEntity,
|
|
knowledge_entity_embedding::KnowledgeEntityEmbedding,
|
|
text_chunk::TextChunk,
|
|
text_chunk_embedding::TextChunkEmbedding,
|
|
text_content::TextContent,
|
|
},
|
|
},
|
|
};
|
|
use tokio::time::{sleep, Duration};
|
|
use tracing::warn;
|
|
|
|
use super::{
|
|
config::IngestionTuning,
|
|
context::{EmbeddedKnowledgeEntity, EmbeddedTextChunk, PipelineArtifacts},
|
|
};
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
#[allow(clippy::struct_field_names)]
|
|
pub struct PersistCounts {
|
|
pub chunk_count: usize,
|
|
pub entity_count: usize,
|
|
pub relationship_count: usize,
|
|
}
|
|
|
|
/// Persists all pipeline artifacts in one database transaction.
|
|
pub async fn persist_artifacts(
|
|
db: &SurrealDbClient,
|
|
tuning: &IngestionTuning,
|
|
embedding_dimensions: usize,
|
|
artifacts: PipelineArtifacts,
|
|
) -> Result<PersistCounts, AppError> {
|
|
let PipelineArtifacts {
|
|
text_content,
|
|
entities,
|
|
relationships,
|
|
chunks,
|
|
} = artifacts;
|
|
|
|
let source_id = text_content.id.clone();
|
|
let user_id = text_content.user_id.clone();
|
|
let chunk_count = chunks.len();
|
|
let entity_count = entities.len();
|
|
let relationship_count = relationships.len();
|
|
|
|
let (entities, entity_embeddings) = prepare_entity_rows(entities, embedding_dimensions)?;
|
|
let (chunks, chunk_embeddings) = prepare_chunk_rows(chunks, embedding_dimensions)?;
|
|
|
|
let payload = PersistPayload {
|
|
source_id: Arc::from(source_id),
|
|
user_id: Arc::from(user_id),
|
|
text_content: Arc::new(text_content),
|
|
entities: Arc::from(entities.into_boxed_slice()),
|
|
entity_embeddings: Arc::from(entity_embeddings.into_boxed_slice()),
|
|
chunks: Arc::from(chunks.into_boxed_slice()),
|
|
chunk_embeddings: Arc::from(chunk_embeddings.into_boxed_slice()),
|
|
relationships: relationships.into(),
|
|
};
|
|
|
|
let mut backoff_ms = tuning.persist_initial_backoff_ms;
|
|
let last_attempt = tuning.persist_attempts.saturating_sub(1);
|
|
|
|
for attempt in 0..tuning.persist_attempts {
|
|
let result = execute_persist_transaction(db, &payload).await;
|
|
|
|
match result {
|
|
Ok(()) => {
|
|
return Ok(PersistCounts {
|
|
chunk_count,
|
|
entity_count,
|
|
relationship_count,
|
|
});
|
|
}
|
|
Err(err) => {
|
|
if is_retryable_conflict(&err) && attempt < last_attempt {
|
|
let next_attempt = attempt.saturating_add(1);
|
|
warn!(
|
|
attempt = next_attempt,
|
|
"Transient SurrealDB conflict while persisting ingestion artifacts; retrying"
|
|
);
|
|
sleep(Duration::from_millis(backoff_ms)).await;
|
|
backoff_ms = backoff_ms
|
|
.saturating_mul(2)
|
|
.min(tuning.persist_max_backoff_ms);
|
|
continue;
|
|
}
|
|
|
|
return Err(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(AppError::InternalError(
|
|
"Failed to persist ingestion artifacts after retries".to_string(),
|
|
))
|
|
}
|
|
|
|
struct PersistPayload {
|
|
source_id: Arc<str>,
|
|
user_id: Arc<str>,
|
|
text_content: Arc<TextContent>,
|
|
entities: Arc<[KnowledgeEntity]>,
|
|
entity_embeddings: Arc<[KnowledgeEntityEmbedding]>,
|
|
chunks: Arc<[TextChunk]>,
|
|
chunk_embeddings: Arc<[TextChunkEmbedding]>,
|
|
relationships:
|
|
Arc<[common::storage::types::knowledge_relationship::KnowledgeRelationship]>,
|
|
}
|
|
|
|
async fn execute_persist_transaction(
|
|
db: &SurrealDbClient,
|
|
payload: &PersistPayload,
|
|
) -> Result<(), AppError> {
|
|
#[cfg(test)]
|
|
if test_persist_should_fail() {
|
|
return Err(AppError::InternalError(
|
|
"Failed to commit transaction due to a read or write conflict".into(),
|
|
));
|
|
}
|
|
|
|
let mut query = String::from("BEGIN TRANSACTION;\n");
|
|
query.push_str(TextContent::CLEAR_INGESTED_CHILD_ROWS_SURQL);
|
|
query.push_str(
|
|
"DELETE type::thing('text_content', $source_id);
|
|
UPSERT type::thing('text_content', $source_id) CONTENT $text_content;",
|
|
);
|
|
|
|
if !payload.entities.is_empty() {
|
|
query.push_str("\nINSERT INTO knowledge_entity $entities;");
|
|
query.push_str("\nINSERT INTO knowledge_entity_embedding $entity_embeddings;");
|
|
}
|
|
if !payload.chunks.is_empty() {
|
|
query.push_str("\nINSERT INTO text_chunk $chunks;");
|
|
query.push_str("\nINSERT INTO text_chunk_embedding $chunk_embeddings;");
|
|
}
|
|
if !payload.relationships.is_empty() {
|
|
query.push_str(
|
|
r#"
|
|
LET $relationships = $relationships;
|
|
FOR $relationship IN $relationships {
|
|
LET $in_node = type::thing('knowledge_entity', $relationship.`in`);
|
|
LET $out_node = type::thing('knowledge_entity', $relationship.out);
|
|
RELATE $in_node->relates_to->$out_node CONTENT {
|
|
id: type::thing('relates_to', $relationship.id),
|
|
metadata: $relationship.metadata
|
|
};
|
|
};"#,
|
|
);
|
|
}
|
|
|
|
query.push_str("\nCOMMIT TRANSACTION;");
|
|
|
|
let mut request = db
|
|
.client
|
|
.query(query)
|
|
.bind(("source_id", Arc::clone(&payload.source_id)))
|
|
.bind(("user_id", Arc::clone(&payload.user_id)))
|
|
.bind(("text_content", Arc::clone(&payload.text_content)));
|
|
|
|
if !payload.entities.is_empty() {
|
|
request = request
|
|
.bind(("entities", Arc::clone(&payload.entities)))
|
|
.bind(("entity_embeddings", Arc::clone(&payload.entity_embeddings)));
|
|
}
|
|
if !payload.chunks.is_empty() {
|
|
request = request
|
|
.bind(("chunks", Arc::clone(&payload.chunks)))
|
|
.bind(("chunk_embeddings", Arc::clone(&payload.chunk_embeddings)));
|
|
}
|
|
if !payload.relationships.is_empty() {
|
|
request = request.bind(("relationships", Arc::clone(&payload.relationships)));
|
|
}
|
|
|
|
request
|
|
.await
|
|
.map_err(AppError::from)?
|
|
.check()
|
|
.map_err(AppError::from)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn prepare_entity_rows(
|
|
embedded: Vec<EmbeddedKnowledgeEntity>,
|
|
embedding_dimensions: usize,
|
|
) -> Result<(Vec<KnowledgeEntity>, Vec<KnowledgeEntityEmbedding>), AppError> {
|
|
let mut entities = Vec::with_capacity(embedded.len());
|
|
let mut entity_embeddings = Vec::with_capacity(embedded.len());
|
|
|
|
for item in embedded {
|
|
KnowledgeEntityEmbedding::validate_dimension(&item.embedding, embedding_dimensions)?;
|
|
let entity = item.entity;
|
|
entity_embeddings.push(KnowledgeEntityEmbedding::new(
|
|
&entity.id,
|
|
entity.source_id.clone(),
|
|
item.embedding,
|
|
entity.user_id.clone(),
|
|
));
|
|
entities.push(entity);
|
|
}
|
|
|
|
Ok((entities, entity_embeddings))
|
|
}
|
|
|
|
fn prepare_chunk_rows(
|
|
embedded: Vec<EmbeddedTextChunk>,
|
|
embedding_dimensions: usize,
|
|
) -> Result<(Vec<TextChunk>, Vec<TextChunkEmbedding>), AppError> {
|
|
let mut chunks = Vec::with_capacity(embedded.len());
|
|
let mut chunk_embeddings = Vec::with_capacity(embedded.len());
|
|
|
|
for item in embedded {
|
|
TextChunkEmbedding::validate_dimension(&item.embedding, embedding_dimensions)?;
|
|
let chunk = item.chunk;
|
|
chunk_embeddings.push(TextChunkEmbedding::new(
|
|
&chunk.id,
|
|
chunk.source_id.clone(),
|
|
item.embedding,
|
|
chunk.user_id.clone(),
|
|
));
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
Ok((chunks, chunk_embeddings))
|
|
}
|
|
|
|
fn is_retryable_conflict(error: &AppError) -> bool {
|
|
error
|
|
.to_string()
|
|
.contains("Failed to commit transaction due to a read or write conflict")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static TEST_PERSIST_FAILURES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
|
|
|
#[cfg(test)]
|
|
fn set_test_persist_failures(count: usize) {
|
|
TEST_PERSIST_FAILURES.store(count, std::sync::atomic::Ordering::SeqCst);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn test_persist_should_fail() -> bool {
|
|
let remaining = TEST_PERSIST_FAILURES.load(std::sync::atomic::Ordering::SeqCst);
|
|
if remaining == 0 {
|
|
return false;
|
|
}
|
|
TEST_PERSIST_FAILURES.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use common::storage::types::text_content::TextContent;
|
|
|
|
use super::*;
|
|
use crate::pipeline::test_support::{
|
|
self, count_chunks_for_source, count_entities_for_source, count_relationships_for_source,
|
|
large_artifacts, persist, sample_artifacts, setup_db, TEST_EMBEDDING_DIM,
|
|
};
|
|
|
|
#[tokio::test]
|
|
async fn persist_artifacts_is_idempotent_for_same_source() -> anyhow::Result<()> {
|
|
let db = setup_db().await?;
|
|
let source_id = uuid::Uuid::new_v4().to_string();
|
|
let user_id = "persist-idempotent";
|
|
|
|
persist(&db, sample_artifacts(&source_id, user_id)).await?;
|
|
persist(&db, sample_artifacts(&source_id, user_id)).await?;
|
|
|
|
assert_eq!(count_chunks_for_source(&db, &source_id).await?, 1);
|
|
assert_eq!(count_entities_for_source(&db, &source_id).await?, 1);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persist_artifacts_rejects_invalid_embedding_before_write() -> anyhow::Result<()> {
|
|
let db = setup_db().await?;
|
|
let source_id = uuid::Uuid::new_v4().to_string();
|
|
let user_id = "persist-validate";
|
|
let mut artifacts = sample_artifacts(&source_id, user_id);
|
|
if let Some(chunk) = artifacts.chunks.first_mut() {
|
|
chunk.embedding = vec![0.1; 2];
|
|
}
|
|
|
|
let result =
|
|
persist_artifacts(&db, &test_support::tuning(), TEST_EMBEDDING_DIM, artifacts).await;
|
|
assert!(result.is_err());
|
|
|
|
let text: Option<TextContent> = db.get_item(&source_id).await?;
|
|
assert!(text.is_none());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persist_large_snapshot() -> anyhow::Result<()> {
|
|
let db = setup_db().await?;
|
|
let source_id = uuid::Uuid::new_v4().to_string();
|
|
let user_id = "persist-large";
|
|
let chunk_count = 100;
|
|
let entity_count = 20;
|
|
let relationship_count = 30;
|
|
|
|
persist(
|
|
&db,
|
|
large_artifacts(
|
|
&source_id,
|
|
user_id,
|
|
chunk_count,
|
|
entity_count,
|
|
relationship_count,
|
|
TEST_EMBEDDING_DIM,
|
|
),
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(count_chunks_for_source(&db, &source_id).await?, chunk_count);
|
|
assert_eq!(
|
|
count_entities_for_source(&db, &source_id).await?,
|
|
entity_count
|
|
);
|
|
assert_eq!(
|
|
count_relationships_for_source(&db, &source_id).await?,
|
|
relationship_count
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persist_does_not_touch_other_source_ids() -> anyhow::Result<()> {
|
|
let db = setup_db().await?;
|
|
let source_a = uuid::Uuid::new_v4().to_string();
|
|
let source_b = uuid::Uuid::new_v4().to_string();
|
|
let user_id = "persist-isolation";
|
|
|
|
persist(&db, large_artifacts(&source_a, user_id, 5, 3, 4, TEST_EMBEDDING_DIM)).await?;
|
|
persist(&db, large_artifacts(&source_b, user_id, 2, 1, 1, TEST_EMBEDDING_DIM)).await?;
|
|
persist(
|
|
&db,
|
|
large_artifacts(&source_a, user_id, 7, 4, 6, TEST_EMBEDDING_DIM),
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(count_chunks_for_source(&db, &source_a).await?, 7);
|
|
assert_eq!(count_entities_for_source(&db, &source_a).await?, 4);
|
|
assert_eq!(count_relationships_for_source(&db, &source_a).await?, 6);
|
|
assert_eq!(count_chunks_for_source(&db, &source_b).await?, 2);
|
|
assert_eq!(count_entities_for_source(&db, &source_b).await?, 1);
|
|
assert_eq!(count_relationships_for_source(&db, &source_b).await?, 1);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn is_retryable_conflict_matches_surreal_transaction_conflict() {
|
|
let err = AppError::InternalError(
|
|
"Failed to commit transaction due to a read or write conflict".into(),
|
|
);
|
|
assert!(is_retryable_conflict(&err));
|
|
}
|
|
|
|
#[test]
|
|
fn is_retryable_conflict_rejects_unrelated_errors() {
|
|
let err = AppError::Validation("invalid payload".into());
|
|
assert!(!is_retryable_conflict(&err));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persist_artifacts_retries_transient_conflicts() -> anyhow::Result<()> {
|
|
set_test_persist_failures(2);
|
|
|
|
let db = setup_db().await?;
|
|
let source_id = uuid::Uuid::new_v4().to_string();
|
|
let user_id = "persist-retry";
|
|
let mut tuning = test_support::tuning();
|
|
tuning.persist_attempts = 3;
|
|
tuning.persist_initial_backoff_ms = 1;
|
|
tuning.persist_max_backoff_ms = 1;
|
|
|
|
let counts = persist_artifacts(
|
|
&db,
|
|
&tuning,
|
|
TEST_EMBEDDING_DIM,
|
|
sample_artifacts(&source_id, user_id),
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(counts.chunk_count, 1);
|
|
assert_eq!(count_chunks_for_source(&db, &source_id).await?, 1);
|
|
|
|
Ok(())
|
|
}
|
|
}
|