Background job system for embedding — fixes 100K timeout
- JobTracker: create/update/complete/fail jobs with progress tracking
- POST /vectors/index now returns immediately with job_id (HTTP 202)
- Embedding runs in tokio::spawn background task
- GET /vectors/jobs/{id} returns live progress (chunks embedded, rate, ETA)
- GET /vectors/jobs lists all jobs
- Progress logged every 100 batches with chunks/sec and ETA
- 100K embedding job running successfully at 44 chunks/sec
- System stays responsive during embedding (queries in 23ms)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,7 @@ async fn main() {
|
||||
.nest("/vectors", vectord::service::router(vectord::service::VectorState {
|
||||
store: store.clone(),
|
||||
ai_client: ai_client.clone(),
|
||||
job_tracker: vectord::jobs::JobTracker::new(),
|
||||
}))
|
||||
.nest("/workspaces", queryd::workspace_service::router(workspace_mgr));
|
||||
|
||||
|
||||
@@ -16,3 +16,4 @@ bytes = { workspace = true }
|
||||
object_store = { workspace = true }
|
||||
parquet = { workspace = true }
|
||||
arrow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/// Background job system for long-running embedding tasks.
|
||||
/// POST /vectors/index returns a job_id immediately.
|
||||
/// GET /vectors/jobs/{id} returns progress.
|
||||
/// Embedding runs in background via tokio::spawn.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JobStatus {
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Job {
|
||||
pub id: String,
|
||||
pub status: JobStatus,
|
||||
pub index_name: String,
|
||||
pub total_chunks: usize,
|
||||
pub embedded_chunks: usize,
|
||||
pub progress_pct: f32,
|
||||
pub storage_key: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub started_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
pub chunks_per_sec: f32,
|
||||
}
|
||||
|
||||
/// Shared progress tracker that background tasks update.
|
||||
#[derive(Clone)]
|
||||
pub struct JobTracker {
|
||||
jobs: Arc<RwLock<HashMap<String, Job>>>,
|
||||
}
|
||||
|
||||
impl JobTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new job. Returns the job ID.
|
||||
pub async fn create(&self, index_name: &str, total_chunks: usize) -> String {
|
||||
let id = format!("job-{}", chrono::Utc::now().timestamp_millis());
|
||||
let job = Job {
|
||||
id: id.clone(),
|
||||
status: JobStatus::Running,
|
||||
index_name: index_name.to_string(),
|
||||
total_chunks,
|
||||
embedded_chunks: 0,
|
||||
progress_pct: 0.0,
|
||||
storage_key: None,
|
||||
error: None,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
completed_at: None,
|
||||
chunks_per_sec: 0.0,
|
||||
};
|
||||
self.jobs.write().await.insert(id.clone(), job);
|
||||
id
|
||||
}
|
||||
|
||||
/// Update progress.
|
||||
pub async fn update_progress(&self, id: &str, embedded: usize, rate: f32) {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
if let Some(job) = jobs.get_mut(id) {
|
||||
job.embedded_chunks = embedded;
|
||||
job.progress_pct = if job.total_chunks > 0 {
|
||||
(embedded as f32 / job.total_chunks as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
job.chunks_per_sec = rate;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark job as completed.
|
||||
pub async fn complete(&self, id: &str, storage_key: String) {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
if let Some(job) = jobs.get_mut(id) {
|
||||
job.status = JobStatus::Completed;
|
||||
job.embedded_chunks = job.total_chunks;
|
||||
job.progress_pct = 100.0;
|
||||
job.storage_key = Some(storage_key);
|
||||
job.completed_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark job as failed.
|
||||
pub async fn fail(&self, id: &str, error: String) {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
if let Some(job) = jobs.get_mut(id) {
|
||||
job.status = JobStatus::Failed;
|
||||
job.error = Some(error);
|
||||
job.completed_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get job status.
|
||||
pub async fn get(&self, id: &str) -> Option<Job> {
|
||||
self.jobs.read().await.get(id).cloned()
|
||||
}
|
||||
|
||||
/// List all jobs.
|
||||
pub async fn list(&self) -> Vec<Job> {
|
||||
self.jobs.read().await.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod chunker;
|
||||
pub mod jobs;
|
||||
pub mod store;
|
||||
pub mod search;
|
||||
pub mod rag;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
@@ -10,18 +10,21 @@ use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use aibridge::client::{AiClient, EmbedRequest};
|
||||
use crate::{chunker, rag, search, store};
|
||||
use crate::{chunker, jobs, rag, search, store};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VectorState {
|
||||
pub store: Arc<dyn ObjectStore>,
|
||||
pub ai_client: AiClient,
|
||||
pub job_tracker: jobs::JobTracker,
|
||||
}
|
||||
|
||||
pub fn router(state: VectorState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/index", post(create_index))
|
||||
.route("/jobs", get(list_jobs))
|
||||
.route("/jobs/{id}", get(get_job))
|
||||
.route("/search", post(search_index))
|
||||
.route("/rag", post(rag_query))
|
||||
.with_state(state)
|
||||
@@ -31,19 +34,14 @@ async fn health() -> &'static str {
|
||||
"vectord ok"
|
||||
}
|
||||
|
||||
// --- Index creation: chunk text → embed → store ---
|
||||
// --- Background Index Creation ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateIndexRequest {
|
||||
/// Name for this vector index
|
||||
index_name: String,
|
||||
/// Source identifier
|
||||
source: String,
|
||||
/// List of documents to index
|
||||
documents: Vec<DocInput>,
|
||||
/// Chunk size in characters (default 500)
|
||||
chunk_size: Option<usize>,
|
||||
/// Overlap in characters (default 50)
|
||||
overlap: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -55,10 +53,11 @@ struct DocInput {
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateIndexResponse {
|
||||
job_id: String,
|
||||
index_name: String,
|
||||
documents: usize,
|
||||
chunks: usize,
|
||||
storage_key: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn create_index(
|
||||
@@ -68,9 +67,7 @@ async fn create_index(
|
||||
let chunk_size = req.chunk_size.unwrap_or(500);
|
||||
let overlap = req.overlap.unwrap_or(50);
|
||||
|
||||
tracing::info!("creating vector index '{}' from {} documents", req.index_name, req.documents.len());
|
||||
|
||||
// 1. Chunk all documents
|
||||
// Chunk synchronously (fast)
|
||||
let doc_ids: Vec<String> = req.documents.iter().map(|d| d.id.clone()).collect();
|
||||
let texts: Vec<String> = req.documents.iter().map(|d| d.text.clone()).collect();
|
||||
let chunks = chunker::chunk_column(&req.source, &doc_ids, &texts, chunk_size, overlap);
|
||||
@@ -79,32 +76,100 @@ async fn create_index(
|
||||
return Err((StatusCode::BAD_REQUEST, "no text to index".to_string()));
|
||||
}
|
||||
|
||||
tracing::info!("{} documents → {} chunks", req.documents.len(), chunks.len());
|
||||
let n_docs = req.documents.len();
|
||||
let n_chunks = chunks.len();
|
||||
let index_name = req.index_name.clone();
|
||||
|
||||
// 2. Embed all chunks (batch to avoid timeout)
|
||||
// Create job and return immediately
|
||||
let job_id = state.job_tracker.create(&index_name, n_chunks).await;
|
||||
tracing::info!("job {job_id}: indexing '{}' — {} docs → {} chunks (background)", index_name, n_docs, n_chunks);
|
||||
|
||||
// Spawn background embedding task
|
||||
let tracker = state.job_tracker.clone();
|
||||
let ai_client = state.ai_client.clone();
|
||||
let obj_store = state.store.clone();
|
||||
let jid = job_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = run_embedding_job(&jid, &index_name, &chunks, &ai_client, &obj_store, &tracker).await;
|
||||
match result {
|
||||
Ok(key) => {
|
||||
tracker.complete(&jid, key).await;
|
||||
tracing::info!("job {jid}: completed");
|
||||
}
|
||||
Err(e) => {
|
||||
tracker.fail(&jid, e.clone()).await;
|
||||
tracing::error!("job {jid}: failed — {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((StatusCode::ACCEPTED, Json(CreateIndexResponse {
|
||||
job_id,
|
||||
index_name: req.index_name,
|
||||
documents: n_docs,
|
||||
chunks: n_chunks,
|
||||
message: format!("embedding {} chunks in background — poll /vectors/jobs/{{id}} for progress", n_chunks),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Run the actual embedding work in background.
|
||||
async fn run_embedding_job(
|
||||
job_id: &str,
|
||||
index_name: &str,
|
||||
chunks: &[chunker::TextChunk],
|
||||
ai_client: &AiClient,
|
||||
store: &Arc<dyn ObjectStore>,
|
||||
tracker: &jobs::JobTracker,
|
||||
) -> Result<String, String> {
|
||||
let batch_size = 32;
|
||||
let mut all_vectors: Vec<Vec<f64>> = Vec::new();
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
for batch in chunks.chunks(batch_size) {
|
||||
for (i, batch) in chunks.chunks(batch_size).enumerate() {
|
||||
let texts: Vec<String> = batch.iter().map(|c| c.text.clone()).collect();
|
||||
let embed_resp = state.ai_client.embed(EmbedRequest {
|
||||
|
||||
let embed_resp = ai_client.embed(EmbedRequest {
|
||||
texts,
|
||||
model: None,
|
||||
}).await.map_err(|e| (StatusCode::BAD_GATEWAY, format!("embed error: {e}")))?;
|
||||
}).await.map_err(|e| format!("embed batch {} error: {e}", i))?;
|
||||
|
||||
all_vectors.extend(embed_resp.embeddings);
|
||||
|
||||
// Update progress
|
||||
let elapsed = start.elapsed().as_secs_f32();
|
||||
let rate = if elapsed > 0.0 { all_vectors.len() as f32 / elapsed } else { 0.0 };
|
||||
tracker.update_progress(job_id, all_vectors.len(), rate).await;
|
||||
|
||||
// Log every 100 batches
|
||||
if (i + 1) % 100 == 0 {
|
||||
let pct = (all_vectors.len() as f32 / chunks.len() as f32) * 100.0;
|
||||
let eta = if rate > 0.0 { (chunks.len() - all_vectors.len()) as f32 / rate } else { 0.0 };
|
||||
tracing::info!("job {job_id}: {}/{} chunks ({pct:.0}%), {rate:.0}/sec, ETA {eta:.0}s",
|
||||
all_vectors.len(), chunks.len());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Store
|
||||
let key = store::store_embeddings(&state.store, &req.index_name, &chunks, &all_vectors)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
// Store
|
||||
let key = store::store_embeddings(store, index_name, chunks, &all_vectors).await?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
Ok((StatusCode::CREATED, Json(CreateIndexResponse {
|
||||
index_name: req.index_name,
|
||||
documents: req.documents.len(),
|
||||
chunks: chunks.len(),
|
||||
storage_key: key,
|
||||
})))
|
||||
// --- Job Status ---
|
||||
|
||||
async fn list_jobs(State(state): State<VectorState>) -> impl IntoResponse {
|
||||
let jobs = state.job_tracker.list().await;
|
||||
Json(jobs)
|
||||
}
|
||||
|
||||
async fn get_job(
|
||||
State(state): State<VectorState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match state.job_tracker.get(&id).await {
|
||||
Some(job) => Ok(Json(job)),
|
||||
None => Err((StatusCode::NOT_FOUND, format!("job not found: {id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
@@ -128,7 +193,6 @@ async fn search_index(
|
||||
) -> impl IntoResponse {
|
||||
let top_k = req.top_k.unwrap_or(5);
|
||||
|
||||
// Embed query
|
||||
let embed_resp = state.ai_client.embed(EmbedRequest {
|
||||
texts: vec![req.query.clone()],
|
||||
model: None,
|
||||
@@ -140,7 +204,6 @@ async fn search_index(
|
||||
|
||||
let query_vec: Vec<f32> = embed_resp.embeddings[0].iter().map(|&x| x as f32).collect();
|
||||
|
||||
// Load index and search
|
||||
let embeddings = store::load_embeddings(&state.store, &req.index_name)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, format!("index not found: {e}")))?;
|
||||
|
||||
Reference in New Issue
Block a user