use crate::db::Store;
use crate::embedding::service::EmbeddingService;
use anyhow::{Context, Result};
use tracing::{debug, error, info, warn};
#[derive(Debug, Clone)]
pub struct PipelineConfig {
pub batch_size: usize,
pub incremental: bool,
pub dry_run: bool,
pub sample_size: Option<usize>,
pub batch_delay_ms: u64,
pub max_cost_usd: Option<f64>,
}
impl Default for PipelineConfig {
fn default() -> Self {
Self {
batch_size: 100,
incremental: true,
dry_run: false,
sample_size: None,
batch_delay_ms: 100,
max_cost_usd: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PipelineStats {
pub total_chunks: usize,
pub embeddings_generated: usize,
pub embeddings_cached: usize,
pub copied_from_cache: usize,
pub cost_saved_usd: f64,
pub failed_chunks: usize,
pub api_calls: usize,
pub total_tokens: u64,
pub estimated_cost_usd: f64,
pub cache_hit_rate: f64,
pub duration_secs: f64,
pub dimension: usize,
pub provider: String,
}
impl PipelineStats {
pub fn chunks_per_second(&self) -> f64 {
if self.duration_secs > 0.0 {
self.total_chunks as f64 / self.duration_secs
} else {
0.0
}
}
pub fn summary(&self) -> String {
format!(
"Processed {} chunks in {:.1}s ({:.1} chunks/s)\n\
Provider: {} ({} dimensions)\n\
Generated: {}, Cached: {}, Copied from DB: {}, Failed: {}\n\
Cache hit rate: {:.1}%\n\
API calls: {}, Tokens: {}, Cost: ${:.4}\n\
Cost saved from reuse: ${:.4}",
self.total_chunks,
self.duration_secs,
self.chunks_per_second(),
self.provider,
self.dimension,
self.embeddings_generated,
self.embeddings_cached,
self.copied_from_cache,
self.failed_chunks,
self.cache_hit_rate * 100.0,
self.api_calls,
self.total_tokens,
self.estimated_cost_usd,
self.cost_saved_usd
)
}
}
pub struct EmbeddingPipeline {
service: EmbeddingService,
config: PipelineConfig,
dimension: usize,
provider_name: String,
}
impl EmbeddingPipeline {
pub fn new(service: EmbeddingService, config: PipelineConfig) -> Self {
let dimension = service.dimension();
let provider_name = service.provider_name().to_string();
info!(
"Initialized embedding pipeline: provider={}, dimension={}",
provider_name, dimension
);
Self {
service,
config,
dimension,
provider_name,
}
}
pub fn dimension(&self) -> usize {
self.dimension
}
pub fn provider_name(&self) -> &str {
&self.provider_name
}
pub async fn copy_existing_embeddings(
&self,
store: &(dyn Store + Send + Sync),
) -> Result<usize> {
info!("Copying existing embeddings from code_embeddings table");
let count = store
.copy_existing_embeddings_from_cache()
.await
.context("Failed to copy embeddings from code_embeddings table")?;
if count > 0 {
info!(
"Copied embeddings for {} chunks from code_embeddings table",
count
);
} else {
debug!("No embeddings to copy from code_embeddings table");
}
Ok(count as usize)
}
pub async fn run(&self, store: &(dyn Store + Send + Sync)) -> Result<PipelineStats> {
self.run_with_progress(store, None).await
}
pub async fn run_with_progress(
&self,
store: &(dyn Store + Send + Sync),
progress_callback: Option<&dyn Fn(usize, usize)>,
) -> Result<PipelineStats> {
let start_time = std::time::Instant::now();
let mut stats = PipelineStats {
dimension: self.dimension,
provider: self.provider_name.clone(),
..Default::default()
};
info!("Starting embedding generation pipeline");
info!(
"Config: batch_size={}, incremental={}, dry_run={}, sample_size={:?}",
self.config.batch_size,
self.config.incremental,
self.config.dry_run,
self.config.sample_size
);
info!(
"Provider: {} (dimension: {})",
self.provider_name, self.dimension
);
if let Err(e) = store.mark_stale_runs_as_failed().await {
warn!("Failed to mark stale encoding runs as failed: {}", e);
}
match self.copy_existing_embeddings(store).await {
Ok(copied_count) => {
stats.copied_from_cache = copied_count;
stats.cost_saved_usd = copied_count as f64 * 0.00013;
info!(
"Copied {} embeddings from cache, saved ${:.4}",
copied_count, stats.cost_saved_usd
);
}
Err(e) => {
warn!("Failed to copy embeddings from cache: {}", e);
}
}
let chunks = self.fetch_chunks_needing_embeddings(store).await?;
stats.total_chunks = chunks.len();
if chunks.is_empty() {
info!("No chunks need embeddings");
return Ok(stats);
}
info!("Found {} chunks needing embeddings", chunks.len());
let run_id: Option<i64> = match store
.create_encoding_run(
chunks.len() as i64,
Some(&self.provider_name),
Some(self.dimension as i32),
)
.await
{
Ok(id) => {
info!("Created encoding run {} for {} chunks", id, chunks.len());
Some(id)
}
Err(e) => {
warn!("Failed to create encoding run record: {}", e);
None
}
};
let processing_result = self
.run_batch_loop(
store,
&chunks,
&mut stats,
progress_callback,
start_time,
run_id,
)
.await;
let cache_metrics = self.service.cache_metrics().await;
stats.cache_hit_rate = cache_metrics.hit_rate();
if let Some(provider_metrics) = self.service.provider_metrics() {
stats.total_tokens = provider_metrics.total_tokens;
stats.estimated_cost_usd = provider_metrics.estimated_cost_usd;
stats.api_calls = provider_metrics.total_requests as usize;
}
stats.duration_secs = start_time.elapsed().as_secs_f64();
if let Some(id) = run_id {
let final_status = if processing_result.is_ok() {
"completed"
} else {
"failed"
};
if let Err(e) = store.complete_encoding_run(id, final_status).await {
warn!("Failed to mark encoding run as {}: {}", final_status, e);
}
}
processing_result?;
info!("Pipeline completed");
info!("{}", stats.summary());
Ok(stats)
}
async fn run_batch_loop(
&self,
store: &(dyn Store + Send + Sync),
chunks: &[ChunkRow],
stats: &mut PipelineStats,
progress_callback: Option<&dyn Fn(usize, usize)>,
start_time: std::time::Instant,
run_id: Option<i64>,
) -> Result<()> {
for (batch_idx, batch) in chunks.chunks(self.config.batch_size).enumerate() {
let batch_num = batch_idx + 1;
let total_batches = chunks.len().div_ceil(self.config.batch_size);
info!(
"Processing batch {}/{} ({} chunks)",
batch_num,
total_batches,
batch.len()
);
if let Some(max_cost) = self.config.max_cost_usd {
if let Some(metrics) = self.service.provider_metrics() {
let current_cost = metrics.estimated_cost_usd;
if current_cost > 0.0 && current_cost >= max_cost {
warn!(
"Cost ceiling reached: ${:.4} >= ${:.4}",
current_cost, max_cost
);
break;
}
}
}
match self.process_batch(store, batch, stats).await {
Ok(_) => {
debug!("Batch {} completed successfully", batch_num);
}
Err(e) => {
warn!("Batch {} failed: {}", batch_num, e);
stats.failed_chunks += batch.len();
}
}
if batch_idx < total_batches - 1 {
tokio::time::sleep(tokio::time::Duration::from_millis(
self.config.batch_delay_ms,
))
.await;
}
let progress = ((batch_num as f64 / total_batches as f64) * 100.0) as u32;
info!("Progress: {}% ({}/{})", progress, batch_num, total_batches);
if let Some(callback) = progress_callback {
let chunks_processed =
std::cmp::min(batch_num * self.config.batch_size, chunks.len());
callback(chunks_processed, chunks.len());
}
if let Some(id) = run_id {
let chunks_completed =
std::cmp::min(batch_num * self.config.batch_size, chunks.len()) as i64;
let elapsed_secs = start_time.elapsed().as_secs_f64();
let chunks_per_second = if elapsed_secs > 0.0 {
chunks_completed as f64 / elapsed_secs
} else {
0.0
};
if let Err(e) = store
.update_encoding_run_progress(id, chunks_completed, Some(chunks_per_second))
.await
{
warn!("Failed to update encoding run progress: {}", e);
}
}
}
if stats.failed_chunks > 0 && stats.failed_chunks >= stats.total_chunks {
anyhow::bail!(
"All {} chunks failed during embedding generation",
stats.total_chunks
);
}
Ok(())
}
async fn fetch_chunks_needing_embeddings(
&self,
store: &(dyn Store + Send + Sync),
) -> Result<Vec<ChunkRow>> {
let chunks = store
.fetch_chunks_needing_embeddings(self.config.incremental, self.config.sample_size)
.await
.context("Failed to fetch chunks")?;
let chunk_rows: Vec<ChunkRow> = chunks
.into_iter()
.map(|chunk| ChunkRow {
id: chunk.id,
signature: chunk.signature,
docstring: chunk.docstring,
preview: chunk.preview,
blob_sha: Some(chunk.blob_sha),
})
.collect();
Ok(chunk_rows)
}
async fn process_batch(
&self,
store: &(dyn Store + Send + Sync),
batch: &[ChunkRow],
stats: &mut PipelineStats,
) -> Result<()> {
let code_texts: Vec<String> = batch
.iter()
.map(|chunk| self.prepare_code_text(chunk))
.collect();
let text_texts: Vec<String> = batch
.iter()
.map(|chunk| self.prepare_text_summary(chunk))
.collect();
let (code_embeddings, code_batch_stats) =
match self.service.embed_batch_with_stats(code_texts).await {
Ok(result) => result,
Err(e) => {
error!("Failed to generate code embeddings: {:?}", e);
return Err(e).context("Failed to generate code embeddings");
}
};
stats.embeddings_generated += code_batch_stats.from_api;
stats.embeddings_cached += code_batch_stats.cached;
let (text_embeddings, text_batch_stats) = self
.service
.embed_batch_with_stats(text_texts)
.await
.context("Failed to generate text embeddings")?;
stats.embeddings_generated += text_batch_stats.from_api;
stats.embeddings_cached += text_batch_stats.cached;
self.validate_embeddings(&code_embeddings)?;
self.validate_embeddings(&text_embeddings)?;
if !self.config.dry_run {
for (i, chunk) in batch.iter().enumerate() {
if let Some(blob_sha) = &chunk.blob_sha {
store
.upsert_embedding(blob_sha, &code_embeddings[i], &self.provider_name)
.await?;
}
}
debug!("Wrote {} chunk embeddings to database", batch.len());
} else {
debug!("Dry run: skipped writing {} embeddings", batch.len());
}
Ok(())
}
fn prepare_code_text(&self, chunk: &ChunkRow) -> String {
let mut parts = Vec::new();
if let Some(sig) = &chunk.signature {
if !sig.is_empty() {
parts.push(sig.clone());
}
}
if let Some(doc) = &chunk.docstring {
if !doc.is_empty() {
parts.push(doc.clone());
}
}
parts.push(chunk.preview.clone());
parts.join("\n")
}
fn prepare_text_summary(&self, chunk: &ChunkRow) -> String {
if let Some(doc) = &chunk.docstring {
if !doc.is_empty() {
return doc.clone();
}
}
if let Some(sig) = &chunk.signature {
if !sig.is_empty() {
return sig.clone();
}
}
chunk.preview.clone()
}
fn validate_embeddings(&self, embeddings: &[Vec<f32>]) -> Result<()> {
for emb in embeddings.iter() {
if emb.len() != self.dimension {
use crate::embedding::error::{DimensionMismatchError, EmbeddingError};
return Err(
EmbeddingError::DimensionMismatch(DimensionMismatchError::new(
self.dimension,
emb.len(),
self.provider_name.clone(),
"unknown".to_string(), self.dimension,
))
.into(),
);
}
}
Ok(())
}
pub async fn process_missing_embeddings(
&self,
store: &(dyn Store + Send + Sync),
_repo: &str, _worktree: &str, ) -> Result<PipelineStats> {
info!(
"Finding chunks missing {}-dimensional embeddings (provider: {})",
self.dimension, self.provider_name
);
let all_chunks = store
.fetch_chunks_needing_embeddings(true, None)
.await
.context("Failed to query chunks missing embeddings")?;
let chunk_ids: Vec<i64> = all_chunks.iter().map(|c| c.id).collect();
info!(
"Found {} chunks missing {}-dimensional embeddings (provider: {})",
chunk_ids.len(),
self.dimension,
self.provider_name
);
if chunk_ids.is_empty() {
return Ok(PipelineStats {
dimension: self.dimension,
provider: self.provider_name.clone(),
..Default::default()
});
}
let chunks = self.fetch_chunks_by_ids(store, &chunk_ids).await?;
let start_time = std::time::Instant::now();
let mut stats = PipelineStats {
dimension: self.dimension,
provider: self.provider_name.clone(),
total_chunks: chunks.len(),
..Default::default()
};
for (batch_idx, batch) in chunks.chunks(self.config.batch_size).enumerate() {
let batch_num = batch_idx + 1;
let total_batches = chunks.len().div_ceil(self.config.batch_size);
info!(
"Processing incremental batch {}/{} ({} chunks)",
batch_num,
total_batches,
batch.len()
);
if let Some(max_cost) = self.config.max_cost_usd {
if let Some(metrics) = self.service.provider_metrics() {
let current_cost = metrics.estimated_cost_usd;
if current_cost > 0.0 && current_cost >= max_cost {
warn!(
"Cost ceiling reached: ${:.4} >= ${:.4}",
current_cost, max_cost
);
break;
}
}
}
match self.process_batch(store, batch, &mut stats).await {
Ok(_) => {
debug!("Incremental batch {} completed successfully", batch_num);
}
Err(e) => {
warn!("Incremental batch {} failed: {}", batch_num, e);
stats.failed_chunks += batch.len();
}
}
if batch_idx < total_batches - 1 {
tokio::time::sleep(tokio::time::Duration::from_millis(
self.config.batch_delay_ms,
))
.await;
}
let progress = ((batch_num as f64 / total_batches as f64) * 100.0) as u32;
info!(
"Incremental progress: {}% ({}/{})",
progress, batch_num, total_batches
);
}
let cache_metrics = self.service.cache_metrics().await;
stats.cache_hit_rate = cache_metrics.hit_rate();
if let Some(provider_metrics) = self.service.provider_metrics() {
stats.total_tokens = provider_metrics.total_tokens;
stats.estimated_cost_usd = provider_metrics.estimated_cost_usd;
stats.api_calls = provider_metrics.total_requests as usize;
}
stats.duration_secs = start_time.elapsed().as_secs_f64();
info!("Incremental embedding generation completed");
info!("{}", stats.summary());
Ok(stats)
}
async fn fetch_chunks_by_ids(
&self,
store: &(dyn Store + Send + Sync),
chunk_ids: &[i64],
) -> Result<Vec<ChunkRow>> {
if chunk_ids.is_empty() {
return Ok(Vec::new());
}
let all_chunks = store
.fetch_chunks_needing_embeddings(true, None)
.await
.context("Failed to fetch chunks by IDs")?;
let chunk_id_set: std::collections::HashSet<i64> = chunk_ids.iter().copied().collect();
let chunks: Vec<ChunkRow> = all_chunks
.into_iter()
.filter(|c| chunk_id_set.contains(&c.id))
.map(|chunk| ChunkRow {
id: chunk.id,
signature: chunk.signature,
docstring: chunk.docstring,
preview: chunk.preview,
blob_sha: Some(chunk.blob_sha),
})
.collect();
Ok(chunks)
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)] struct ChunkRow {
id: i64,
signature: Option<String>,
docstring: Option<String>,
preview: String,
blob_sha: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embedding::cache::EmbeddingCache;
use crate::embedding::config::CacheConfig;
use crate::embedding::error::EmbeddingError;
use crate::embedding::provider::{EmbeddingProvider, ProviderMetrics};
use async_trait::async_trait;
use std::sync::Arc;
struct MockProvider {
dimension: usize,
name: &'static str,
}
#[async_trait]
impl EmbeddingProvider for MockProvider {
async fn embed(&self, _text: String) -> Result<Vec<f32>, EmbeddingError> {
Ok(vec![0.0; self.dimension])
}
async fn embed_batch(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbeddingError> {
Ok(vec![vec![0.0; self.dimension]; texts.len()])
}
fn dimension(&self) -> usize {
self.dimension
}
fn provider_name(&self) -> &'static str {
self.name
}
fn metrics(&self) -> Option<ProviderMetrics> {
Some(ProviderMetrics {
total_requests: 10,
total_tokens: 1000,
failed_requests: 0,
estimated_cost_usd: 0.001,
})
}
}
fn create_test_service(dimension: usize, name: &'static str) -> EmbeddingService {
let provider = Box::new(MockProvider { dimension, name });
let cache_config = CacheConfig {
max_entries: 100,
ttl_seconds: 3600,
enable_metrics: true,
};
let cache = EmbeddingCache::new(cache_config).unwrap();
EmbeddingService::new(provider, Arc::new(cache))
}
#[test]
fn test_pipeline_config_defaults() {
let config = PipelineConfig::default();
assert_eq!(config.batch_size, 100);
assert!(config.incremental);
assert!(!config.dry_run);
assert_eq!(config.sample_size, None);
assert_eq!(config.batch_delay_ms, 100);
assert_eq!(config.max_cost_usd, None);
}
#[test]
fn test_pipeline_stats_summary() {
let stats = PipelineStats {
total_chunks: 1000,
embeddings_generated: 200,
embeddings_cached: 800,
copied_from_cache: 0,
cost_saved_usd: 0.0,
failed_chunks: 0,
api_calls: 10,
total_tokens: 50000,
estimated_cost_usd: 1.0,
cache_hit_rate: 0.8,
duration_secs: 10.0,
dimension: 1536,
provider: "openai".to_string(),
};
assert_eq!(stats.chunks_per_second(), 100.0);
assert!(stats.summary().contains("1000 chunks"));
assert!(stats.summary().contains("$1.0000"));
assert!(stats.summary().contains("openai"));
assert!(stats.summary().contains("1536 dimensions"));
}
#[test]
fn test_pipeline_dimension_caching() {
let service = create_test_service(768, "ollama");
let config = PipelineConfig::default();
let pipeline = EmbeddingPipeline::new(service, config);
assert_eq!(pipeline.dimension(), 768);
assert_eq!(pipeline.provider_name(), "ollama");
}
#[test]
fn test_pipeline_dimension_matches_service() {
let service = create_test_service(1536, "openai");
let config = PipelineConfig::default();
let expected_dim = service.dimension();
let expected_provider = service.provider_name().to_string();
let pipeline = EmbeddingPipeline::new(service, config);
assert_eq!(pipeline.dimension(), expected_dim);
assert_eq!(pipeline.provider_name(), expected_provider);
}
#[test]
fn test_prepare_code_text() {
let service = create_test_service(1536, "openai");
let config = PipelineConfig::default();
let pipeline = EmbeddingPipeline::new(service, config);
let chunk = ChunkRow {
id: 1,
signature: Some("function foo()".to_string()),
docstring: Some("A test function".to_string()),
preview: "console.log('test')".to_string(),
blob_sha: Some("abc123".to_string()),
};
let text = pipeline.prepare_code_text(&chunk);
assert!(text.contains("function foo()"));
assert!(text.contains("A test function"));
assert!(text.contains("console.log"));
}
#[test]
fn test_prepare_text_summary() {
let service = create_test_service(1536, "openai");
let config = PipelineConfig::default();
let pipeline = EmbeddingPipeline::new(service, config);
let chunk = ChunkRow {
id: 1,
signature: Some("function foo()".to_string()),
docstring: Some("A test function".to_string()),
preview: "console.log('test')".to_string(),
blob_sha: Some("abc123".to_string()),
};
let text = pipeline.prepare_text_summary(&chunk);
assert_eq!(text, "A test function");
}
#[test]
fn test_validate_embeddings() {
let service = create_test_service(1536, "openai");
let config = PipelineConfig::default();
let pipeline = EmbeddingPipeline::new(service, config);
let valid_embeddings = vec![vec![0.1; 1536], vec![0.2; 1536]];
assert!(pipeline.validate_embeddings(&valid_embeddings).is_ok());
let invalid_embeddings = vec![vec![0.1; 768], vec![0.2; 1536]];
assert!(pipeline.validate_embeddings(&invalid_embeddings).is_err());
}
#[test]
fn test_validate_embeddings_dimension_mismatch() {
let service = create_test_service(768, "ollama");
let config = PipelineConfig::default();
let pipeline = EmbeddingPipeline::new(service, config);
let wrong_dim_embeddings = vec![vec![0.1; 1536]];
let result = pipeline.validate_embeddings(&wrong_dim_embeddings);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("768"));
assert!(err_msg.contains("1536"));
assert!(err_msg.contains("ollama"));
}
use crate::db::sqlite::SqliteStore;
use crate::db::traits::StoreChunks;
use crate::db::traits::StoreCore;
use crate::db::traits::StoreEncoding;
use crate::db::{ChunkRecord, FileRecord};
use rusqlite::params;
async fn setup_test_store() -> SqliteStore {
SqliteStore::connect(":memory:").await.unwrap()
}
async fn setup_pipeline_test_data(store: &SqliteStore, num_chunks: usize) {
let repo_id = store
.get_or_create_repo("test-repo", "/test/path")
.await
.unwrap();
let worktree_id = store
.get_or_create_worktree(repo_id, "main", "/test/path")
.await
.unwrap();
let commit_id = store
.get_or_create_commit(repo_id, "abc123", None)
.await
.unwrap();
let file = FileRecord {
repo_id,
worktree_id,
commit_id,
relpath: "test.rs".to_string(),
language: Some("rust".to_string()),
content_hash: "hash_test".to_string(),
size_bytes: 100,
last_modified: None,
};
let file_id = store.upsert_file(&file).await.unwrap();
for i in 0..num_chunks {
let chunk = ChunkRecord {
file_id,
worktree_id,
blob_sha: format!("blob_test_{}", i),
symbol_name: Some(format!("fn_{}", i)),
kind: "function".to_string(),
signature: Some(format!("fn fn_{}()", i)),
docstring: Some(format!("Test function {}", i)),
start_line: (i * 10 + 1) as i32,
end_line: (i * 10 + 10) as i32,
preview: format!("fn fn_{}() {{}}", i),
ts_doc_text: String::new(),
recency_score: 1.0,
churn_score: 0.5,
metadata: None,
};
store.insert_chunk(&chunk).await.unwrap();
}
}
#[tokio::test]
async fn test_pipeline_progress_persisted_during_run() {
let store = setup_test_store().await;
setup_pipeline_test_data(&store, 5).await;
let service = create_test_service(1536, "openai");
let config = PipelineConfig {
batch_size: 2,
incremental: true,
dry_run: false,
sample_size: None,
batch_delay_ms: 0,
max_cost_usd: None,
};
let pipeline = EmbeddingPipeline::new(service, config);
let stats = pipeline.run(&store).await.unwrap();
assert_eq!(stats.total_chunks, 5);
let active_run = store.get_active_encoding_run().await.unwrap();
assert!(active_run.is_none(), "Run should be completed, not active");
store
.run(move |conn| {
let (status, total_chunks, chunks_completed, provider, dimension, finished_at): (
String,
i64,
i64,
Option<String>,
Option<i32>,
Option<String>,
) = conn.query_row(
"SELECT status, total_chunks, chunks_completed, provider, dimension, finished_at FROM encoding_runs ORDER BY id DESC LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)),
)?;
assert_eq!(status, "completed");
assert_eq!(total_chunks, 5);
assert_eq!(chunks_completed, 5);
assert_eq!(provider, Some("openai".to_string()));
assert_eq!(dimension, Some(1536));
assert!(finished_at.is_some(), "finished_at should be set");
let cps: Option<f64> = conn.query_row(
"SELECT chunks_per_second FROM encoding_runs ORDER BY id DESC LIMIT 1",
[],
|row| row.get(0),
)?;
assert!(cps.is_some(), "chunks_per_second should have been set");
assert!(cps.unwrap() > 0.0, "chunks_per_second should be positive");
Ok(())
})
.await
.unwrap();
}
#[tokio::test]
async fn test_pipeline_stale_runs_cleaned_on_startup() {
let store = setup_test_store().await;
let stale_run_id = store
.create_encoding_run(500, Some("ollama"), Some(768))
.await
.unwrap();
let active = store.get_active_encoding_run().await.unwrap();
assert!(active.is_some(), "Stale run should be active initially");
assert_eq!(active.unwrap().id, stale_run_id);
setup_pipeline_test_data(&store, 2).await;
let service = create_test_service(1536, "openai");
let config = PipelineConfig {
batch_size: 10,
incremental: true,
dry_run: false,
sample_size: None,
batch_delay_ms: 0,
max_cost_usd: None,
};
let pipeline = EmbeddingPipeline::new(service, config);
let _stats = pipeline.run(&store).await.unwrap();
store
.run(move |conn| {
let status: String = conn.query_row(
"SELECT status FROM encoding_runs WHERE id = ?1",
params![stale_run_id],
|row| row.get(0),
)?;
assert_eq!(status, "failed", "Stale run should be marked as failed");
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM encoding_runs WHERE status = 'completed'",
[],
|row| row.get(0),
)?;
assert_eq!(
count, 1,
"There should be exactly one completed run (the new pipeline run)"
);
Ok(())
})
.await
.unwrap();
}
#[tokio::test]
async fn test_pipeline_no_run_created_when_no_chunks() {
let store = setup_test_store().await;
let service = create_test_service(1536, "openai");
let config = PipelineConfig::default();
let pipeline = EmbeddingPipeline::new(service, config);
let stats = pipeline.run(&store).await.unwrap();
assert_eq!(stats.total_chunks, 0);
store
.run(move |conn| {
let count: i64 =
conn.query_row("SELECT COUNT(*) FROM encoding_runs", [], |row| row.get(0))?;
assert_eq!(
count, 0,
"No encoding run should be created when there are no chunks"
);
Ok(())
})
.await
.unwrap();
}
#[tokio::test]
async fn test_pipeline_progress_tracks_provider_and_dimension() {
let store = setup_test_store().await;
setup_pipeline_test_data(&store, 3).await;
let service = create_test_service(768, "ollama");
let config = PipelineConfig {
batch_size: 10,
incremental: true,
dry_run: false,
sample_size: None,
batch_delay_ms: 0,
max_cost_usd: None,
};
let pipeline = EmbeddingPipeline::new(service, config);
let _stats = pipeline.run(&store).await.unwrap();
store
.run(move |conn| {
let (provider, dimension): (Option<String>, Option<i32>) = conn.query_row(
"SELECT provider, dimension FROM encoding_runs ORDER BY id DESC LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(provider, Some("ollama".to_string()));
assert_eq!(dimension, Some(768));
Ok(())
})
.await
.unwrap();
}
struct FailingProvider {
dimension: usize,
name: &'static str,
}
#[async_trait]
impl EmbeddingProvider for FailingProvider {
async fn embed(&self, _text: String) -> Result<Vec<f32>, EmbeddingError> {
Err(EmbeddingError::Other(
"simulated embedding failure".to_string(),
))
}
async fn embed_batch(&self, _texts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbeddingError> {
Err(EmbeddingError::Other(
"simulated batch embedding failure".to_string(),
))
}
fn dimension(&self) -> usize {
self.dimension
}
fn provider_name(&self) -> &'static str {
self.name
}
fn metrics(&self) -> Option<ProviderMetrics> {
None
}
}
fn create_failing_service(dimension: usize, name: &'static str) -> EmbeddingService {
let provider = Box::new(FailingProvider { dimension, name });
let cache_config = CacheConfig {
max_entries: 100,
ttl_seconds: 3600,
enable_metrics: true,
};
let cache = EmbeddingCache::new(cache_config).unwrap();
EmbeddingService::new(provider, Arc::new(cache))
}
#[tokio::test]
async fn test_pipeline_run_marks_encoding_run_as_failed_on_error() {
let store = setup_test_store().await;
setup_pipeline_test_data(&store, 3).await;
let service = create_failing_service(1536, "failing-provider");
let config = PipelineConfig {
batch_size: 2,
incremental: true,
dry_run: false,
sample_size: None,
batch_delay_ms: 0,
max_cost_usd: None,
};
let pipeline = EmbeddingPipeline::new(service, config);
let result = pipeline.run(&store).await;
assert!(
result.is_err(),
"Pipeline should return an error when all chunks fail"
);
let active_run = store.get_active_encoding_run().await.unwrap();
assert!(
active_run.is_none(),
"No run should be active (running) after a failure"
);
store
.run(move |conn| {
let (status, total_chunks, finished_at): (String, i64, Option<String>) =
conn.query_row(
"SELECT status, total_chunks, finished_at FROM encoding_runs ORDER BY id DESC LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(status, "failed", "Encoding run should be marked as 'failed'");
assert_eq!(total_chunks, 3);
assert!(
finished_at.is_some(),
"finished_at should be set even on failure"
);
Ok(())
})
.await
.unwrap();
}
}