mirror of
https://github.com/perstarkse/minne.git
synced 2026-07-11 23:32:53 +02:00
420 lines
14 KiB
Rust
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(())
|
|
}
|
|
}
|