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, pipeline_config: IngestionConfig, services: Arc, } impl IngestionPipeline { pub fn new( db: Arc, openai_client: Arc>, config: AppConfig, reranker_pool: Option>, storage: StorageManager, embedding_provider: Arc, ) -> Result { Self::new_with_config( db, openai_client, config, reranker_pool, storage, embedding_provider, IngestionConfig::default(), ) } pub fn new_with_config( db: Arc, openai_client: Arc>, config: AppConfig, reranker_pool: Option>, storage: StorageManager, embedding_provider: Arc, pipeline_config: IngestionConfig, ) -> Result { 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, pipeline_config: IngestionConfig, services: Arc, ) -> Result { 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 { Ok(self.db.get_item::(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 { 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 = 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(()) } }