Skip to main content

linkrs_server/http/handlers/
rebuild.rs

1//! Async index rebuild tasks (fulltext + vector).
2//!
3//! Rebuilds are long-running: POST handlers accept the request, spawn a
4//! background task driving the sync-side online rebuild driver, and return
5//! a rebuild id immediately. Callers poll the status endpoint, which merges
6//! the stored task record with live rebuild progress while running.
7//! Clear endpoints are synchronous and destructive, requiring `force=true`.
8
9#[cfg(any(feature = "fulltext", feature = "vector"))]
10use axum::{
11    extract::{Extension, Path, State},
12    response::Json as JsonResponse,
13    Json,
14};
15use serde::Serialize;
16use std::sync::Arc;
17
18#[cfg(any(feature = "fulltext", feature = "vector"))]
19use crate::http::{error::HttpError, state::AppState};
20#[cfg(any(feature = "fulltext", feature = "vector"))]
21use crate::storage::{
22    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
23};
24
25/// One async rebuild task.
26#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
27pub struct RebuildTaskRecord {
28    pub rebuild_id: String,
29    /// `fulltext` or `vector`.
30    pub kind: String,
31    pub space_id: u64,
32    pub tag_name: String,
33    pub field_name: String,
34    /// `running`, `completed`, or `failed`.
35    pub status: String,
36    /// Applied point/document operations, set on completion.
37    #[serde(default)]
38    pub applied: Option<u64>,
39    /// Failure reason, set on failure.
40    #[serde(default)]
41    pub error: Option<String>,
42    pub started_at_ms: i64,
43}
44
45/// In-memory registry of rebuild tasks, shared via [`crate::http::HttpServer`].
46#[derive(Debug, Clone, Default)]
47pub struct RebuildTaskRegistry {
48    tasks: Arc<dashmap::DashMap<String, RebuildTaskRecord>>,
49}
50
51impl RebuildTaskRegistry {
52    pub fn register_running(
53        &self,
54        rebuild_id: String,
55        kind: &str,
56        space_id: u64,
57        tag_name: &str,
58        field_name: &str,
59    ) {
60        self.tasks.insert(
61            rebuild_id.clone(),
62            RebuildTaskRecord {
63                rebuild_id,
64                kind: kind.to_string(),
65                space_id,
66                tag_name: tag_name.to_string(),
67                field_name: field_name.to_string(),
68                status: "running".to_string(),
69                applied: None,
70                error: None,
71                started_at_ms: chrono::Utc::now().timestamp_millis(),
72            },
73        );
74    }
75
76    /// Register a running task unless one is already running for the same
77    /// index. Returns false when the request must be rejected with `409
78    /// Conflict`: the sync-side driver would return `RebuildBusy` for the
79    /// same overlap, so fail fast before spawning the background task.
80    pub fn try_register_running(
81        &self,
82        rebuild_id: String,
83        kind: &str,
84        space_id: u64,
85        tag_name: &str,
86        field_name: &str,
87    ) -> bool {
88        if self.tasks.iter().any(|entry| {
89            entry.value().status == "running"
90                && entry.value().kind == kind
91                && entry.value().space_id == space_id
92                && entry.value().tag_name == tag_name
93                && entry.value().field_name == field_name
94        }) {
95            return false;
96        }
97        self.register_running(rebuild_id, kind, space_id, tag_name, field_name);
98        true
99    }
100
101    pub fn finish(&self, rebuild_id: &str, result: Result<u64, String>) {
102        if let Some(mut record) = self.tasks.get_mut(rebuild_id) {
103            match result {
104                Ok(applied) => {
105                    record.status = "completed".to_string();
106                    record.applied = Some(applied);
107                }
108                Err(error) => {
109                    record.status = "failed".to_string();
110                    record.error = Some(error);
111                }
112            }
113        }
114    }
115
116    pub fn get(&self, rebuild_id: &str) -> Option<RebuildTaskRecord> {
117        self.tasks.get(rebuild_id).map(|entry| entry.clone())
118    }
119}
120
121#[cfg(any(feature = "fulltext", feature = "vector"))]
122fn sync_manager_or_error<S>(
123    state: &AppState<S>,
124    target: &str,
125) -> Result<Arc<linkrs_sync::SyncManager>, HttpError>
126where
127    S: StorageClient
128        + StorageSchemaContextOps
129        + StorageSyncContextOps
130        + StorageOperationContextOps
131        + Clone
132        + 'static,
133{
134    state
135        .server
136        .get_graph_service()
137        .sync_api()
138        .map(|api| api.sync_manager().clone())
139        .ok_or_else(|| {
140            HttpError::InternalError(format!(
141                "{target} rebuild requires a configured sync manager"
142            ))
143        })
144}
145
146#[cfg(any(feature = "fulltext", feature = "vector"))]
147fn resolve_space_name<S>(storage: &S, space_id: u64) -> Result<String, HttpError>
148where
149    S: StorageClient,
150{
151    storage
152        .get_space_by_id(space_id)
153        .map_err(|error| HttpError::InternalError(error.to_string()))?
154        .map(|info| info.space_name)
155        .ok_or_else(|| HttpError::NotFound(format!("space id {space_id} not found")))
156}
157
158// ── Fulltext ──────────────────────────────────────────────────────────────
159
160#[utoipa::path(
161    post,
162    path = "/v1/fulltext/indexes/rebuild",
163    tag = "Fulltext",
164    request_body = linkrs_wire::fulltext::RebuildFulltextIndexRequest,
165    responses(
166        (status = 200, body = linkrs_wire::fulltext::RebuildFulltextIndexResponse, description = "Fulltext rebuild started"),
167        (status = 404, description = "Not found"),
168        (status = 500, description = "Internal error")
169    )
170)]
171/// Start an async fulltext index rebuild.
172#[cfg(feature = "fulltext")]
173pub async fn rebuild_fulltext<
174    S: StorageClient
175        + StorageSchemaContextOps
176        + StorageSyncContextOps
177        + StorageOperationContextOps
178        + Clone
179        + Send
180        + Sync
181        + 'static,
182>(
183    State(state): State<AppState<S>>,
184    Extension(session_id): Extension<i64>,
185    Json(request): Json<linkrs_wire::fulltext::RebuildFulltextIndexRequest>,
186) -> Result<JsonResponse<linkrs_wire::fulltext::RebuildFulltextIndexResponse>, HttpError> {
187    super::authz::require_admin_session(&state, session_id)?;
188    use linkrs_api::api_core::rebuild_source::StorageRebuildSource;
189
190    let sync_manager = sync_manager_or_error(&state, "fulltext")?;
191    let storage = state.server.get_storage().read().clone();
192    let space_name = resolve_space_name(&storage, request.space_id)?;
193    let rebuild_id = uuid::Uuid::new_v4().to_string();
194    let registry = state.server.rebuild_tasks();
195    if !registry.try_register_running(
196        rebuild_id.clone(),
197        "fulltext",
198        request.space_id,
199        &request.tag_name,
200        &request.field_name,
201    ) {
202        return Err(HttpError::Conflict(format!(
203            "fulltext rebuild already running for {}.{}.{}",
204            request.space_id, request.tag_name, request.field_name
205        )));
206    }
207    let tasks = registry.clone();
208    let task_id = rebuild_id.clone();
209    let (space_id, tag_name, field_name) = (
210        request.space_id,
211        request.tag_name.clone(),
212        request.field_name.clone(),
213    );
214    tokio::spawn(async move {
215        let mut source = StorageRebuildSource::new(
216            &storage,
217            space_name,
218            tag_name.clone(),
219            field_name.clone(),
220            500,
221        );
222        let result = sync_manager
223            .rebuild_fulltext_index(
224                space_id,
225                &tag_name,
226                &field_name,
227                &mut source,
228                linkrs_sync::FulltextRebuildOptions::default(),
229            )
230            .await
231            .map_err(|error| error.to_string());
232        tasks.finish(&task_id, result);
233    });
234    Ok(JsonResponse(
235        linkrs_wire::fulltext::RebuildFulltextIndexResponse {
236            rebuild_id,
237            status: "running".to_string(),
238            space_id: request.space_id,
239            tag_name: request.tag_name,
240            field_name: request.field_name,
241        },
242    ))
243}
244
245#[utoipa::path(
246    get,
247    path = "/v1/fulltext/rebuilds/{id}",
248    tag = "Fulltext",
249    params(("id" = String, Path, description = "Rebuild task id")),
250    responses(
251        (status = 200, body = linkrs_wire::fulltext::FulltextRebuildStatusResponse, description = "Fulltext rebuild status"),
252        (status = 404, description = "Not found"),
253        (status = 500, description = "Internal error")
254    )
255)]
256/// Poll a fulltext rebuild task, merging live driver progress while running.
257#[cfg(feature = "fulltext")]
258pub async fn fulltext_rebuild_status<
259    S: StorageClient
260        + StorageSchemaContextOps
261        + StorageSyncContextOps
262        + StorageOperationContextOps
263        + Clone
264        + Send
265        + Sync
266        + 'static,
267>(
268    State(state): State<AppState<S>>,
269    Extension(session_id): Extension<i64>,
270    Path(rebuild_id): Path<String>,
271) -> Result<JsonResponse<linkrs_wire::fulltext::FulltextRebuildStatusResponse>, HttpError> {
272    super::authz::require_admin_session(&state, session_id)?;
273    let record = state
274        .server
275        .rebuild_tasks()
276        .get(&rebuild_id)
277        .ok_or_else(|| HttpError::NotFound(format!("rebuild task {rebuild_id} not found")))?;
278    if record.kind != "fulltext" {
279        return Err(HttpError::NotFound(format!(
280            "rebuild task {rebuild_id} is not a fulltext rebuild"
281        )));
282    }
283    let mut response = linkrs_wire::fulltext::FulltextRebuildStatusResponse {
284        rebuild_id: record.rebuild_id.clone(),
285        status: record.status.clone(),
286        phase: None,
287        generation: None,
288        docs_scanned: 0,
289        docs_applied: record.applied.unwrap_or(0),
290        docs_skipped: 0,
291        error: record.error.clone(),
292    };
293    if record.status == "running" {
294        let live = state
295            .server
296            .get_graph_service()
297            .sync_api()
298            .and_then(|api| api.sync_manager().fulltext_manager_opt())
299            .and_then(|manager| {
300                manager.rebuild_progress(record.space_id, &record.tag_name, &record.field_name)
301            });
302        if let Some(progress) = live {
303            response.phase = Some(progress.phase.as_str().to_string());
304            response.generation = Some(progress.generation);
305            response.docs_scanned = progress.docs_scanned;
306            response.docs_applied = progress.docs_applied;
307            response.docs_skipped = progress.docs_skipped;
308        }
309    }
310    Ok(JsonResponse(response))
311}
312
313#[utoipa::path(
314    post,
315    path = "/v1/fulltext/indexes/clear",
316    tag = "Fulltext",
317    request_body = linkrs_wire::fulltext::ClearFulltextIndexRequest,
318    responses(
319        (status = 200, body = linkrs_wire::fulltext::ClearFulltextIndexResponse, description = "Fulltext index cleared"),
320        (status = 500, description = "Internal error")
321    )
322)]
323/// Synchronously clear a fulltext index. Destructive: requires `force=true`.
324#[cfg(feature = "fulltext")]
325pub async fn clear_fulltext<
326    S: StorageClient
327        + StorageSchemaContextOps
328        + StorageSyncContextOps
329        + StorageOperationContextOps
330        + Clone
331        + Send
332        + Sync
333        + 'static,
334>(
335    State(state): State<AppState<S>>,
336    Extension(session_id): Extension<i64>,
337    Json(request): Json<linkrs_wire::fulltext::ClearFulltextIndexRequest>,
338) -> Result<JsonResponse<linkrs_wire::fulltext::ClearFulltextIndexResponse>, HttpError> {
339    super::authz::require_admin_session(&state, session_id)?;
340    if !request.force {
341        return Err(HttpError::BadRequest(
342            "Refusing to clear fulltext index without explicit confirmation: retry with force = true"
343                .to_string(),
344        ));
345    }
346    log::warn!(
347        "Clearing fulltext index via HTTP explicitly confirmed: space {} tag {} field {}",
348        request.space_id,
349        request.tag_name,
350        request.field_name
351    );
352    let sync_manager = sync_manager_or_error(&state, "fulltext")?;
353    let manager = sync_manager
354        .fulltext_manager_opt()
355        .ok_or_else(|| HttpError::InternalError("fulltext target is not configured".to_string()))?;
356    manager
357        .clear_index(
358            request.space_id,
359            &request.tag_name,
360            &request.field_name,
361            true,
362        )
363        .await
364        .map_err(|error| HttpError::InternalError(error.to_string()))?;
365    Ok(JsonResponse(
366        linkrs_wire::fulltext::ClearFulltextIndexResponse {
367            ok: true,
368            space_id: request.space_id,
369            tag_name: request.tag_name,
370            field_name: request.field_name,
371        },
372    ))
373}
374
375#[utoipa::path(
376    get,
377    path = "/v1/fulltext/indexes/inconsistent",
378    tag = "Fulltext",
379    responses(
380        (status = 200, body = serde_json::Value, description = "Inconsistent fulltext indexes"),
381        (status = 500, description = "Internal error")
382    )
383)]
384/// List fulltext indexes whose engine is in the `Inconsistent` state.
385///
386/// These indexes reject writes and need operator attention: trigger the
387/// async online rebuild (`POST /v1/fulltext/indexes/rebuild`) or drop and
388/// recreate the index. Read-only; safe to poll for alerting.
389#[cfg(feature = "fulltext")]
390pub async fn inconsistent_fulltext<
391    S: StorageClient
392        + StorageSchemaContextOps
393        + StorageSyncContextOps
394        + StorageOperationContextOps
395        + Clone
396        + Send
397        + Sync
398        + 'static,
399>(
400    State(state): State<AppState<S>>,
401    Extension(session_id): Extension<i64>,
402) -> Result<JsonResponse<serde_json::Value>, HttpError> {
403    super::authz::require_admin_session(&state, session_id)?;
404    let sync_manager = sync_manager_or_error(&state, "fulltext")?;
405    let inconsistent = sync_manager.inconsistent_fulltext_indexes();
406    let count = inconsistent.len() as u64;
407    state
408        .server
409        .get_graph_service()
410        .get_stats_manager()
411        .record_inconsistent_index_count(count);
412    Ok(JsonResponse(serde_json::json!({
413        "inconsistent": inconsistent,
414        "count": count,
415    })))
416}
417
418// ── Vector ────────────────────────────────────────────────────────────────
419
420#[utoipa::path(
421    post,
422    path = "/v1/vector/indexes/rebuild",
423    tag = "Vector",
424    request_body = linkrs_wire::vector::RebuildVectorIndexRequest,
425    responses(
426        (status = 200, body = linkrs_wire::vector::RebuildVectorIndexResponse, description = "Vector rebuild started"),
427        (status = 404, description = "Not found"),
428        (status = 500, description = "Internal error")
429    )
430)]
431/// Start an async vector index rebuild.
432#[cfg(feature = "vector")]
433pub async fn rebuild_vector<
434    S: StorageClient
435        + StorageSchemaContextOps
436        + StorageSyncContextOps
437        + StorageOperationContextOps
438        + Clone
439        + Send
440        + Sync
441        + 'static,
442>(
443    State(state): State<AppState<S>>,
444    Extension(session_id): Extension<i64>,
445    Json(request): Json<linkrs_wire::vector::RebuildVectorIndexRequest>,
446) -> Result<JsonResponse<linkrs_wire::vector::RebuildVectorIndexResponse>, HttpError> {
447    super::authz::require_admin_session(&state, session_id)?;
448    use linkrs_api::api_core::rebuild_source::StorageVectorSource;
449
450    let sync_manager = sync_manager_or_error(&state, "vector")?;
451    let storage = state.server.get_storage().read().clone();
452    let space_name = resolve_space_name(&storage, request.space_id)?;
453    let rebuild_id = uuid::Uuid::new_v4().to_string();
454    let registry = state.server.rebuild_tasks();
455    if !registry.try_register_running(
456        rebuild_id.clone(),
457        "vector",
458        request.space_id,
459        &request.tag_name,
460        &request.field_name,
461    ) {
462        return Err(HttpError::Conflict(format!(
463            "vector rebuild already running for {}.{}.{}",
464            request.space_id, request.tag_name, request.field_name
465        )));
466    }
467    let tasks = registry.clone();
468    let task_id = rebuild_id.clone();
469    let (space_id, tag_name, field_name) = (
470        request.space_id,
471        request.tag_name.clone(),
472        request.field_name.clone(),
473    );
474    tokio::spawn(async move {
475        let mut source = StorageVectorSource::new(
476            &storage,
477            space_name,
478            tag_name.clone(),
479            field_name.clone(),
480            500,
481        );
482        let result = sync_manager
483            .rebuild_vector_index(
484                space_id,
485                &tag_name,
486                &field_name,
487                &mut source,
488                linkrs_sync::VectorRebuildOptions::default(),
489            )
490            .await
491            .map_err(|error| error.to_string());
492        tasks.finish(&task_id, result);
493    });
494    Ok(JsonResponse(
495        linkrs_wire::vector::RebuildVectorIndexResponse {
496            rebuild_id,
497            status: "running".to_string(),
498            space_id: request.space_id,
499            tag_name: request.tag_name,
500            field_name: request.field_name,
501        },
502    ))
503}
504
505#[utoipa::path(
506    get,
507    path = "/v1/vector/rebuilds/{id}",
508    tag = "Vector",
509    params(("id" = String, Path, description = "Rebuild task id")),
510    responses(
511        (status = 200, body = linkrs_wire::vector::VectorRebuildStatusResponse, description = "Vector rebuild status"),
512        (status = 404, description = "Not found"),
513        (status = 500, description = "Internal error")
514    )
515)]
516/// Poll a vector rebuild task, merging live driver progress while running.
517#[cfg(feature = "vector")]
518pub async fn vector_rebuild_status<
519    S: StorageClient
520        + StorageSchemaContextOps
521        + StorageSyncContextOps
522        + StorageOperationContextOps
523        + Clone
524        + Send
525        + Sync
526        + 'static,
527>(
528    State(state): State<AppState<S>>,
529    Extension(session_id): Extension<i64>,
530    Path(rebuild_id): Path<String>,
531) -> Result<JsonResponse<linkrs_wire::vector::VectorRebuildStatusResponse>, HttpError> {
532    super::authz::require_admin_session(&state, session_id)?;
533    let record = state
534        .server
535        .rebuild_tasks()
536        .get(&rebuild_id)
537        .ok_or_else(|| HttpError::NotFound(format!("rebuild task {rebuild_id} not found")))?;
538    if record.kind != "vector" {
539        return Err(HttpError::NotFound(format!(
540            "rebuild task {rebuild_id} is not a vector rebuild"
541        )));
542    }
543    let mut response = linkrs_wire::vector::VectorRebuildStatusResponse {
544        rebuild_id: record.rebuild_id.clone(),
545        status: record.status.clone(),
546        phase: None,
547        generation: None,
548        vectors_scanned: 0,
549        vectors_applied: record.applied.unwrap_or(0),
550        vectors_skipped: 0,
551        error: record.error.clone(),
552    };
553    if record.status == "running" {
554        let live = state
555            .server
556            .get_graph_service()
557            .sync_api()
558            .and_then(|api| api.sync_manager().vector_coordinator().cloned())
559            .and_then(|coordinator| {
560                coordinator.rebuild_progress(record.space_id, &record.tag_name, &record.field_name)
561            });
562        if let Some(progress) = live {
563            response.phase = Some(progress.phase.as_str().to_string());
564            response.generation = Some(progress.generation);
565            response.vectors_scanned = progress.docs_scanned;
566            response.vectors_applied = progress.docs_applied;
567            response.vectors_skipped = progress.docs_skipped;
568        }
569    }
570    Ok(JsonResponse(response))
571}
572
573#[utoipa::path(
574    post,
575    path = "/v1/vector/indexes/clear",
576    tag = "Vector",
577    request_body = linkrs_wire::vector::ClearVectorIndexRequest,
578    responses(
579        (status = 200, body = linkrs_wire::vector::ClearVectorIndexResponse, description = "Vector index cleared"),
580        (status = 500, description = "Internal error")
581    )
582)]
583/// Synchronously clear a vector index. Destructive: requires `force=true`.
584#[cfg(feature = "vector")]
585pub async fn clear_vector<
586    S: StorageClient
587        + StorageSchemaContextOps
588        + StorageSyncContextOps
589        + StorageOperationContextOps
590        + Clone
591        + Send
592        + Sync
593        + 'static,
594>(
595    State(state): State<AppState<S>>,
596    Extension(session_id): Extension<i64>,
597    Json(request): Json<linkrs_wire::vector::ClearVectorIndexRequest>,
598) -> Result<JsonResponse<linkrs_wire::vector::ClearVectorIndexResponse>, HttpError> {
599    super::authz::require_admin_session(&state, session_id)?;
600    if !request.force {
601        return Err(HttpError::BadRequest(
602            "Refusing to clear vector index without explicit confirmation: retry with force = true"
603                .to_string(),
604        ));
605    }
606    log::warn!(
607        "Clearing vector index via HTTP explicitly confirmed: space {} tag {} field {}",
608        request.space_id,
609        request.tag_name,
610        request.field_name
611    );
612    let sync_manager = sync_manager_or_error(&state, "vector")?;
613    let coordinator = sync_manager
614        .vector_coordinator()
615        .cloned()
616        .ok_or_else(|| HttpError::InternalError("vector target is not configured".to_string()))?;
617    coordinator
618        .index_manager()
619        .purge_index_data(request.space_id, &request.tag_name, &request.field_name)
620        .await
621        .map_err(|error| HttpError::InternalError(error.to_string()))?;
622    Ok(JsonResponse(
623        linkrs_wire::vector::ClearVectorIndexResponse {
624            ok: true,
625            space_id: request.space_id,
626            tag_name: request.tag_name,
627            field_name: request.field_name,
628        },
629    ))
630}