Files
minne/ingestion-pipeline/src/pipeline/mod.rs
T
2026-06-24 22:02:31 +02:00

420 lines
14 KiB
Rust

mod config;
mod context;
mod enrichment_result;
mod persistence;
mod preparation;
mod services;
mod stages;
mod state;
pub use config::{IngestionConfig, IngestionTuning};
#[allow(clippy::module_name_repetitions)]
pub use context::{EmbeddedKnowledgeEntity, EmbeddedTextChunk, PipelineArtifacts};
pub use enrichment_result::{LLMEnrichmentResult, LLMKnowledgeEntity, LLMRelationship};
#[allow(clippy::module_name_repetitions)]
pub use persistence::persist_artifacts;
#[allow(clippy::module_name_repetitions)]
pub use services::{DefaultPipelineServices, PipelineServices};
use std::{
sync::Arc,
time::{Duration, Instant},
};
use async_openai::Client;
use common::{
error::AppError,
storage::{
db::SurrealDbClient,
store::StorageManager,
types::{
ingestion_payload::IngestionPayload,
ingestion_task::{IngestionTask, TaskErrorInfo},
text_content::TextContent,
},
},
utils::config::AppConfig,
};
use retrieval_pipeline::reranking::RerankerPool;
use tokio::time::sleep;
use tracing::{debug, info, warn};
use self::{
context::PipelineContext,
stages::{enrich, persist, prepare_content, retrieve_related},
state::{Enriched, IngestionMachine, ready},
};
/// Wall-clock duration of each pre-persistence pipeline stage.
struct StageTimings {
prepare: Duration,
retrieve: Duration,
enrich: Duration,
}
#[allow(clippy::module_name_repetitions)]
pub struct IngestionPipeline {
db: Arc<SurrealDbClient>,
pipeline_config: IngestionConfig,
services: Arc<dyn PipelineServices>,
}
impl IngestionPipeline {
pub fn new(
db: Arc<SurrealDbClient>,
openai_client: Arc<Client<async_openai::config::OpenAIConfig>>,
config: AppConfig,
reranker_pool: Option<Arc<RerankerPool>>,
storage: StorageManager,
embedding_provider: Arc<common::utils::embedding::EmbeddingProvider>,
) -> Result<Self, AppError> {
Self::new_with_config(
db,
openai_client,
config,
reranker_pool,
storage,
embedding_provider,
IngestionConfig::default(),
)
}
pub fn new_with_config(
db: Arc<SurrealDbClient>,
openai_client: Arc<Client<async_openai::config::OpenAIConfig>>,
config: AppConfig,
reranker_pool: Option<Arc<RerankerPool>>,
storage: StorageManager,
embedding_provider: Arc<common::utils::embedding::EmbeddingProvider>,
pipeline_config: IngestionConfig,
) -> Result<Self, AppError> {
let services = DefaultPipelineServices::new(
Arc::clone(&db),
openai_client,
config,
reranker_pool,
storage,
embedding_provider,
pipeline_config.tuning.embedding_query_char_limit,
);
Self::with_services(db, pipeline_config, Arc::new(services))
}
pub fn with_services(
db: Arc<SurrealDbClient>,
pipeline_config: IngestionConfig,
services: Arc<dyn PipelineServices>,
) -> Result<Self, AppError> {
Ok(Self {
db,
pipeline_config,
services,
})
}
#[tracing::instrument(
skip_all,
fields(
task_id = %task.id,
attempt = task.attempts,
worker_id = task.worker_id.as_deref().unwrap_or("unknown-worker"),
user_id = %task.user_id
)
)]
pub async fn process_task(&self, task: IngestionTask) -> Result<(), AppError> {
let mut processing_task = task.mark_processing(&self.db).await?;
let pipeline_result = if self.artifacts_persisted(&processing_task.id).await? {
info!(
task_id = %processing_task.id,
attempt = processing_task.attempts,
"ingestion artifacts already persisted; skipping pipeline"
);
Ok(())
} else {
let payload = processing_task.take_content();
self.drive_pipeline(&processing_task, payload)
.await
.map_err(|err| {
debug!(
task_id = %processing_task.id,
attempt = processing_task.attempts,
error = %err,
"ingestion pipeline failed"
);
err
})
};
match pipeline_result {
Ok(()) => self.finalize_succeeded(&processing_task).await,
Err(err) => {
let reason = err.to_string();
let retryable = !matches!(err, AppError::Validation(_));
let error_info = TaskErrorInfo {
code: None,
message: reason.clone(),
};
if retryable && processing_task.can_retry() {
let delay = self.retry_delay(processing_task.attempts);
processing_task
.mark_failed(error_info, delay, &self.db)
.await?;
warn!(
task_id = %processing_task.id,
attempt = processing_task.attempts,
retry_in_secs = delay.as_secs(),
"ingestion task failed; scheduled retry"
);
} else {
let failed_task = processing_task
.mark_failed(error_info.clone(), Duration::from_secs(0), &self.db)
.await?;
failed_task.mark_dead_letter(error_info, &self.db).await?;
warn!(
task_id = %failed_task.id,
attempt = failed_task.attempts,
"ingestion task failed; moved to dead letter queue"
);
}
Err(AppError::Processing(reason))
}
}
}
async fn artifacts_persisted(&self, task_id: &str) -> Result<bool, AppError> {
Ok(self.db.get_item::<TextContent>(task_id).await?.is_some())
}
async fn finalize_succeeded(&self, task: &IngestionTask) -> Result<(), AppError> {
let tuning = &self.pipeline_config.tuning;
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 {
match task.mark_succeeded(&self.db).await {
Ok(_) => {
info!(
task_id = %task.id,
attempt = task.attempts,
"ingestion task succeeded"
);
return Ok(());
}
Err(err) if attempt < last_attempt => {
let next_attempt = attempt.saturating_add(1);
warn!(
task_id = %task.id,
attempt = next_attempt,
error = %err,
"failed to mark ingestion task succeeded; retrying"
);
sleep(Duration::from_millis(backoff_ms)).await;
backoff_ms = backoff_ms
.saturating_mul(2)
.min(tuning.persist_max_backoff_ms);
}
Err(err) => return Err(err),
}
}
Err(AppError::InternalError(
"failed to mark ingestion task succeeded after retries".into(),
))
}
fn retry_delay(&self, attempt: u32) -> Duration {
let tuning = &self.pipeline_config.tuning;
let capped_attempt = attempt
.saturating_sub(1)
.min(tuning.retry_backoff_cap_exponent);
let multiplier = 2_u64.pow(capped_attempt);
let delay = tuning.retry_base_delay_secs.saturating_mul(multiplier);
Duration::from_secs(delay.min(tuning.retry_max_delay_secs))
}
fn duration_millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
/// Runs the shared `prepare → retrieve → enrich` stages, recording per-stage timings.
///
/// Both the full task path ([`Self::drive_pipeline`]) and the artifact-only path
/// ([`Self::produce_artifacts`]) share this prefix; only the terminal step differs
/// (persist vs. return artifacts).
async fn run_through_enrichment(
&self,
ctx: &mut PipelineContext<'_>,
payload: IngestionPayload,
) -> Result<(IngestionMachine<(), Enriched>, StageTimings), AppError> {
let machine = ready();
let stage_start = Instant::now();
let machine = prepare_content(machine, ctx, payload)
.await
.map_err(|err| ctx.abort(err))?;
let prepare = stage_start.elapsed();
let stage_start = Instant::now();
let machine = retrieve_related(machine, ctx)
.await
.map_err(|err| ctx.abort(err))?;
let retrieve = stage_start.elapsed();
let stage_start = Instant::now();
let machine = enrich(machine, ctx).await.map_err(|err| ctx.abort(err))?;
let enrich = stage_start.elapsed();
Ok((
machine,
StageTimings {
prepare,
retrieve,
enrich,
},
))
}
#[tracing::instrument(
skip_all,
fields(task_id = %task.id, attempt = task.attempts, user_id = %task.user_id)
)]
async fn drive_pipeline(
&self,
task: &IngestionTask,
payload: IngestionPayload,
) -> Result<(), AppError> {
let mut ctx = PipelineContext::new(
task,
self.db.as_ref(),
&self.pipeline_config,
self.services.as_ref(),
);
let pipeline_started = Instant::now();
let (machine, timings) = self.run_through_enrichment(&mut ctx, payload).await?;
let stage_start = Instant::now();
let _machine = persist(machine, &mut ctx)
.await
.map_err(|err| ctx.abort(err))?;
let persist_duration = stage_start.elapsed();
let total_duration = pipeline_started.elapsed();
info!(
task_id = %ctx.task_id,
attempt = ctx.attempt,
total_ms = Self::duration_millis(total_duration),
prepare_ms = Self::duration_millis(timings.prepare),
retrieve_ms = Self::duration_millis(timings.retrieve),
enrich_ms = Self::duration_millis(timings.enrich),
persist_ms = Self::duration_millis(persist_duration),
"ingestion pipeline finished"
);
Ok(())
}
/// Runs the ingestion pipeline up to (but excluding) persistence and returns the prepared artifacts.
pub async fn produce_artifacts(
&self,
task: &IngestionTask,
) -> Result<PipelineArtifacts, AppError> {
let payload = task.content.clone();
let mut ctx = PipelineContext::new(
task,
self.db.as_ref(),
&self.pipeline_config,
self.services.as_ref(),
);
let (_machine, _timings) = self.run_through_enrichment(&mut ctx, payload).await?;
ctx.build_artifacts().await.map_err(|err| ctx.abort(err))
}
}
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod finalize_tests {
use std::{sync::Arc, time::Duration};
use common::storage::types::{
ingestion_payload::IngestionPayload,
ingestion_task::{IngestionTask, TaskState},
};
use tokio::time::sleep;
use super::{
IngestionPipeline, PipelineServices,
config::IngestionTuning,
test_support::setup_db,
tests::{MockServices, pipeline_config, reserve_task},
};
#[tokio::test]
async fn finalize_succeeded_retries_mark_succeeded() -> anyhow::Result<()> {
use anyhow::Context;
let db = setup_db().await?;
let worker_id = "worker-finalize-retry";
let user_id = "user-finalize-retry";
let services: Arc<dyn PipelineServices> = Arc::new(MockServices::new(user_id));
let mut config = pipeline_config();
config.tuning = IngestionTuning {
persist_attempts: 3,
persist_initial_backoff_ms: 10,
persist_max_backoff_ms: 10,
..IngestionTuning::default()
};
let pipeline = IngestionPipeline::with_services(Arc::new(db.clone()), config, services)?;
let task = reserve_task(
&db,
worker_id,
IngestionPayload::Text {
text: "Finalize retry payload".into(),
context: "Context".into(),
category: "notes".into(),
user_id: user_id.into(),
},
user_id,
)
.await?;
let processing = task.mark_processing(&db).await?;
db.client
.query("UPDATE type::thing('ingestion_task', $id) SET worker_id = $wrong_worker;")
.bind(("id", processing.id.clone()))
.bind(("wrong_worker", "wrong-worker"))
.await?;
let task_id = processing.id.clone();
let db_fix = db.clone();
tokio::spawn(async move {
sleep(Duration::from_millis(5)).await;
let _ = db_fix
.client
.query("UPDATE type::thing('ingestion_task', $id) SET worker_id = $worker_id;")
.bind(("id", task_id))
.bind(("worker_id", worker_id))
.await;
});
pipeline.finalize_succeeded(&processing).await?;
let stored: IngestionTask = db.get_item(&processing.id).await?.context("task stored")?;
assert_eq!(stored.state, TaskState::Succeeded);
Ok(())
}
}