1#[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#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
27pub struct RebuildTaskRecord {
28 pub rebuild_id: String,
29 pub kind: String,
31 pub space_id: u64,
32 pub tag_name: String,
33 pub field_name: String,
34 pub status: String,
36 #[serde(default)]
38 pub applied: Option<u64>,
39 #[serde(default)]
41 pub error: Option<String>,
42 pub started_at_ms: i64,
43}
44
45#[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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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}