Skip to main content

velesdb_server/handlers/
collections.rs

1//! Collection management handlers.
2
3use axum::{
4    extract::{Path, State},
5    http::StatusCode,
6    response::IntoResponse,
7    Json,
8};
9use std::sync::Arc;
10
11use crate::types::{CollectionResponse, CreateCollectionRequest, ErrorResponse};
12use crate::AppState;
13use velesdb_core::index::HnswParams;
14use velesdb_core::{DistanceMetric, StorageMode};
15
16use super::helpers::{
17    auto_core_error_response, error_response, get_collection_or_404, run_blocking,
18};
19
20/// List all collections.
21#[utoipa::path(
22    get,
23    path = "/collections",
24    tag = "collections",
25    responses(
26        (status = 200, description = "List of collections", body = Object)
27    )
28)]
29pub async fn list_collections(State(state): State<Arc<AppState>>) -> impl IntoResponse {
30    let collections = state.db.list_collections();
31    Json(serde_json::json!({ "collections": collections }))
32}
33
34/// Create a new collection.
35#[utoipa::path(
36    post,
37    path = "/collections",
38    tag = "collections",
39    request_body = CreateCollectionRequest,
40    responses(
41        (status = 201, description = "Collection created", body = Object),
42        (status = 400, description = "Invalid request", body = ErrorResponse)
43    )
44)]
45pub async fn create_collection(
46    State(state): State<Arc<AppState>>,
47    Json(req): Json<CreateCollectionRequest>,
48) -> impl IntoResponse {
49    let metric = match parse_distance_metric(&req.metric) {
50        Ok(m) => m,
51        Err(resp) => return resp,
52    };
53
54    let storage_mode = match parse_storage_mode(&req.storage_mode) {
55        Ok(s) => s,
56        Err(resp) => return resp,
57    };
58
59    // Collection creation is synchronous core code (registry locks + disk
60    // I/O + fsync) — run it on the blocking pool.
61    let state_clone = Arc::clone(&state);
62    run_blocking(
63        move || match dispatch_create(&state_clone, &req, metric, storage_mode) {
64            Ok(Ok(())) => create_collection_success_response(&req),
65            Ok(Err(e)) => auto_core_error_response(&e),
66            Err(resp) => resp,
67        },
68    )
69    .await
70    .unwrap_or_else(|resp| resp)
71}
72
73/// Parse a distance metric string into the core enum.
74///
75/// Delegates to [`DistanceMetric::from_str`] to keep alias parsing in one place.
76#[allow(clippy::result_large_err)]
77fn parse_distance_metric(raw: &str) -> Result<DistanceMetric, axum::response::Response> {
78    raw.parse::<DistanceMetric>()
79        .map_err(|e| error_response(StatusCode::BAD_REQUEST, e.to_string()))
80}
81
82/// Parse a storage mode string into the core enum.
83///
84/// Delegates to [`StorageMode::from_str`] (single source of truth in `velesdb-core`).
85#[allow(clippy::result_large_err)]
86fn parse_storage_mode(raw: &str) -> Result<StorageMode, axum::response::Response> {
87    raw.parse::<StorageMode>()
88        .map_err(|e| error_response(StatusCode::BAD_REQUEST, e))
89}
90
91/// Build a full `HnswParams` override from the request fields, or return
92/// `None` when the caller supplied no HNSW tuning fields at all.
93///
94/// The base parameters come from `HnswParams::auto(dimension)` so that
95/// unspecified fields inherit the engine's dimension-aware defaults.
96/// `storage_mode` always mirrors the top-level collection `storage_mode`
97/// — callers cannot desync the HNSW inner storage mode from the
98/// collection's advertised quantisation (the `HnswParams::storage_mode`
99/// field is a denormalised copy that the engine keeps in sync).
100fn build_hnsw_params_override(
101    req: &CreateCollectionRequest,
102    dimension: usize,
103    storage_mode: StorageMode,
104) -> Option<HnswParams> {
105    if req.hnsw_m.is_none()
106        && req.hnsw_ef_construction.is_none()
107        && req.hnsw_alpha.is_none()
108        && req.hnsw_max_elements.is_none()
109    {
110        return None;
111    }
112    let base = HnswParams::auto(dimension);
113    Some(HnswParams {
114        max_connections: req.hnsw_m.unwrap_or(base.max_connections),
115        ef_construction: req.hnsw_ef_construction.unwrap_or(base.ef_construction),
116        max_elements: req.hnsw_max_elements.unwrap_or(base.max_elements),
117        storage_mode,
118        alpha: req.hnsw_alpha.unwrap_or(base.alpha),
119    })
120}
121
122/// Create a vector collection, requiring a dimension in the request.
123///
124/// Applies advanced configuration overrides (pq_rescore_oversampling,
125/// deferred_indexing, async_index_builder) in a second pass via
126/// `VectorCollection::apply_advanced_config` once the base collection
127/// has been registered. This two-step approach keeps the core
128/// `Database::create_vector_collection_*` API stable while still
129/// honouring the full PROP-CONFIG-ADVANCED field set on the REST
130/// surface.
131#[allow(clippy::result_large_err)]
132fn create_vector_collection(
133    state: &AppState,
134    req: &CreateCollectionRequest,
135    metric: DistanceMetric,
136    storage_mode: StorageMode,
137) -> Result<velesdb_core::error::Result<()>, axum::response::Response> {
138    let dimension = req.dimension.ok_or_else(|| {
139        error_response(
140            StatusCode::BAD_REQUEST,
141            "dimension is required for vector collections".to_string(),
142        )
143    })?;
144
145    // Parse the advanced override fields up-front so a malformed JSON
146    // payload fails the request before any collection is created on
147    // disk. This avoids the half-initialised state where the base
148    // collection exists but the advanced fields are missing.
149    let advanced = parse_advanced_config(req)?;
150
151    // Phase 1: create the base collection with HNSW params.
152    //
153    // Any of `hnsw_m`, `hnsw_ef_construction`, `hnsw_alpha`, or
154    // `hnsw_max_elements` being present triggers the "with_params"
155    // path so the caller-supplied values flow into a full `HnswParams`
156    // starting from the engine's dimension-aware auto defaults. The
157    // legacy `with_hnsw` helper cannot carry alpha/max_elements and
158    // would silently drop them, re-introducing the PROP-HNSW-ALPHA gap.
159    let base_result = if let Some(hnsw_params) =
160        build_hnsw_params_override(req, dimension, storage_mode)
161    {
162        // Reject out-of-range tunables (e.g. hnsw_alpha < 1.0 or non-finite)
163        // before any collection is created on disk.
164        hnsw_params
165            .validate()
166            .map_err(|e| error_response(StatusCode::BAD_REQUEST, e.to_string()))?;
167        state.db.create_vector_collection_with_params(
168            &req.name,
169            dimension,
170            metric,
171            storage_mode,
172            hnsw_params,
173            None,
174        )
175    } else {
176        state
177            .db
178            .create_vector_collection_with_options(&req.name, dimension, metric, storage_mode)
179    };
180    if let Err(e) = base_result {
181        return Ok(Err(e));
182    }
183
184    // Phase 2: persist advanced overrides if any were requested.
185    if advanced.has_any() {
186        return Ok(apply_advanced_with_rollback(state, &req.name, advanced));
187    }
188
189    Ok(Ok(()))
190}
191
192/// Applies advanced config overrides with rollback on failure.
193///
194/// If `apply_advanced_config` fails, deletes the collection to avoid
195/// orphaned half-initialised state, and logs diagnostics for operators.
196fn apply_advanced_with_rollback(
197    state: &AppState,
198    name: &str,
199    advanced: AdvancedCreateOverrides,
200) -> velesdb_core::error::Result<()> {
201    let Some(coll) = state.db.get_vector_collection(name) else {
202        return Err(velesdb_core::error::Error::CollectionNotFound(
203            name.to_string(),
204        ));
205    };
206    if let Err(phase_two_err) = coll.apply_advanced_config(
207        advanced.pq_rescore_oversampling,
208        advanced.deferred_indexing,
209        advanced.async_index_builder,
210    ) {
211        drop(coll);
212        let rollback_outcome = state.db.delete_collection(name);
213        if let Err(ref rollback_err) = rollback_outcome {
214            tracing::warn!(
215                collection = %name,
216                rollback_error = %rollback_err,
217                phase_two_error = %phase_two_err,
218                "failed to roll back collection after apply_advanced_config error"
219            );
220        }
221        log_rollback_invariant(state, name, &rollback_outcome, &phase_two_err);
222        return Err(phase_two_err);
223    }
224    Ok(())
225}
226
227/// Logs a critical diagnostic if a collection survives rollback.
228fn log_rollback_invariant(
229    state: &AppState,
230    name: &str,
231    rollback_outcome: &velesdb_core::error::Result<()>,
232    phase_two_err: &velesdb_core::error::Error,
233) {
234    if state.db.get_any_collection(name).is_some() {
235        tracing::error!(
236            collection = %name,
237            rollback_outcome = ?rollback_outcome,
238            phase_two_error = %phase_two_err,
239            "post-rollback invariant violated: collection still present in \
240             registry after delete_collection was attempted. Manual \
241             reconciliation required — client retries will fail with \
242             CollectionExists until the orphaned collection is cleaned up."
243        );
244    }
245}
246
247/// Parsed advanced override fields for the create-collection pipeline.
248///
249/// The outer `Option` signals whether the field was present in the
250/// request body; the inner `Option` carries the value the caller
251/// wanted to persist (including explicit `null` → `Some(None)`).
252/// A local clippy allow is applied because the three-state semantics
253/// are the intended contract here.
254#[allow(clippy::option_option)]
255#[derive(Default)]
256struct AdvancedCreateOverrides {
257    pq_rescore_oversampling: Option<Option<u32>>,
258    deferred_indexing: Option<Option<velesdb_core::collection::streaming::DeferredIndexerConfig>>,
259    async_index_builder:
260        Option<Option<velesdb_core::collection::streaming::AsyncIndexBuilderConfig>>,
261}
262
263impl AdvancedCreateOverrides {
264    fn has_any(&self) -> bool {
265        self.pq_rescore_oversampling.is_some()
266            || self.deferred_indexing.is_some()
267            || self.async_index_builder.is_some()
268    }
269}
270
271/// Parses the advanced override JSON fields on `CreateCollectionRequest`
272/// into typed `CollectionConfig` fragments. A malformed JSON payload
273/// becomes a 400 response.
274#[allow(clippy::result_large_err)]
275fn parse_advanced_config(
276    req: &CreateCollectionRequest,
277) -> Result<AdvancedCreateOverrides, axum::response::Response> {
278    let mut overrides = AdvancedCreateOverrides {
279        pq_rescore_oversampling: req.pq_rescore_oversampling.map(Some),
280        ..Default::default()
281    };
282
283    if let Some(ref value) = req.deferred_indexing {
284        let parsed: velesdb_core::collection::streaming::DeferredIndexerConfig =
285            serde_json::from_value(value.clone()).map_err(|e| {
286                error_response(
287                    StatusCode::BAD_REQUEST,
288                    format!("Invalid 'deferred_indexing' configuration: {e}"),
289                )
290            })?;
291        overrides.deferred_indexing = Some(Some(parsed));
292    }
293
294    if let Some(ref value) = req.async_index_builder {
295        let parsed: velesdb_core::collection::streaming::AsyncIndexBuilderConfig =
296            serde_json::from_value(value.clone()).map_err(|e| {
297                error_response(
298                    StatusCode::BAD_REQUEST,
299                    format!("Invalid 'async_index_builder' configuration: {e}"),
300                )
301            })?;
302        overrides.async_index_builder = Some(Some(parsed));
303    }
304
305    Ok(overrides)
306}
307
308/// Parses the optional `graph_schema` JSON field on
309/// `CreateCollectionRequest` into a typed `GraphSchema`. When the field
310/// is absent the schemaless default is returned, preserving backward
311/// compatibility with callers that relied on the previous behaviour.
312#[allow(clippy::result_large_err)]
313fn parse_graph_schema(
314    req: &CreateCollectionRequest,
315) -> Result<velesdb_core::GraphSchema, axum::response::Response> {
316    match req.graph_schema.as_ref() {
317        Some(value) => serde_json::from_value(value.clone()).map_err(|e| {
318            error_response(
319                StatusCode::BAD_REQUEST,
320                format!("Invalid 'graph_schema' payload: {e}"),
321            )
322        }),
323        None => Ok(velesdb_core::GraphSchema::schemaless()),
324    }
325}
326
327/// Dispatch collection creation based on `collection_type`.
328#[allow(clippy::result_large_err)]
329fn dispatch_create(
330    state: &AppState,
331    req: &CreateCollectionRequest,
332    metric: DistanceMetric,
333    storage_mode: StorageMode,
334) -> Result<velesdb_core::error::Result<()>, axum::response::Response> {
335    match req.collection_type.to_lowercase().as_str() {
336        "metadata_only" | "metadata-only" | "metadata" => {
337            Ok(state.db.create_metadata_collection(&req.name))
338        }
339        "graph" | "knowledge_graph" | "kg" => {
340            let schema = parse_graph_schema(req)?;
341            Ok(state.db.create_graph_collection(&req.name, schema))
342        }
343        "vector" | "" => create_vector_collection(state, req, metric, storage_mode),
344        _ => Err(error_response(
345            StatusCode::BAD_REQUEST,
346            format!(
347                "Invalid collection_type: {}. Valid: vector, graph, metadata_only",
348                req.collection_type
349            ),
350        )),
351    }
352}
353
354/// Build a 201 Created response for successful collection creation.
355fn create_collection_success_response(req: &CreateCollectionRequest) -> axum::response::Response {
356    let mut warnings = Vec::new();
357    let is_vector = matches!(req.collection_type.to_lowercase().as_str(), "vector" | "");
358    if is_vector {
359        warnings.push("Collection dimension and metric are immutable after creation. If your embedding model changes, create a new collection and reindex data.");
360        warnings.push("For first queries, start without strict filters/thresholds, then tighten progressively.");
361    }
362
363    (
364        StatusCode::CREATED,
365        Json(serde_json::json!({
366            "message": "Collection created",
367            "name": req.name,
368            "type": req.collection_type,
369            "warnings": warnings
370        })),
371    )
372        .into_response()
373}
374
375/// Get collection information.
376#[utoipa::path(
377    get,
378    path = "/collections/{name}",
379    tag = "collections",
380    params(
381        ("name" = String, Path, description = "Collection name")
382    ),
383    responses(
384        (status = 200, description = "Collection details", body = CollectionResponse),
385        (status = 404, description = "Collection not found", body = ErrorResponse)
386    )
387)]
388pub async fn get_collection(
389    State(state): State<Arc<AppState>>,
390    Path(name): Path<String>,
391) -> impl IntoResponse {
392    let collection = match get_collection_or_404(&state, &name) {
393        Ok(c) => c,
394        Err(resp) => return resp,
395    };
396
397    let config = collection.config();
398    Json(CollectionResponse {
399        name: config.name,
400        dimension: config.dimension,
401        metric: format!("{:?}", config.metric).to_lowercase(),
402        point_count: config.point_count,
403        storage_mode: format!("{:?}", config.storage_mode).to_lowercase(),
404    })
405    .into_response()
406}
407
408/// Run a quick sanity check for onboarding and troubleshooting.
409#[utoipa::path(
410    get,
411    path = "/collections/{name}/sanity",
412    tag = "collections",
413    params(
414        ("name" = String, Path, description = "Collection name")
415    ),
416    responses(
417        (status = 200, description = "Collection sanity status", body = Object),
418        (status = 404, description = "Collection not found", body = ErrorResponse)
419    )
420)]
421pub async fn collection_sanity(
422    State(state): State<Arc<AppState>>,
423    Path(name): Path<String>,
424) -> impl IntoResponse {
425    let collection = match get_collection_or_404(&state, &name) {
426        Ok(c) => c,
427        Err(resp) => return resp,
428    };
429
430    let config = collection.config();
431    build_sanity_response(&state, &config, &collection)
432}
433
434/// Build the JSON sanity check response body.
435fn build_sanity_response(
436    state: &AppState,
437    config: &velesdb_core::collection::CollectionConfig,
438    collection: &velesdb_core::AnyCollection,
439) -> axum::response::Response {
440    let has_data = config.point_count > 0;
441    Json(serde_json::json!({
442        "collection": config.name,
443        "dimension": config.dimension,
444        "metric": format!("{:?}", config.metric).to_lowercase(),
445        "point_count": config.point_count,
446        "is_empty": collection.is_empty(),
447        "checks": {
448            "has_vectors": has_data,
449            "search_ready": has_data,
450            "dimension_configured": config.dimension > 0
451        },
452        "diagnostics": {
453            "search_requests_total": state.onboarding_metrics.search_requests_total.load(std::sync::atomic::Ordering::Relaxed),
454            "dimension_mismatch_total": state.onboarding_metrics.dimension_mismatch_total.load(std::sync::atomic::Ordering::Relaxed),
455            "empty_search_results_total": state.onboarding_metrics.empty_search_results_total.load(std::sync::atomic::Ordering::Relaxed),
456            "filter_parse_errors_total": state.onboarding_metrics.filter_parse_errors_total.load(std::sync::atomic::Ordering::Relaxed)
457        },
458        "hints": if has_data {
459            vec![
460                "Run a search without strict filters first, then tighten filters progressively."
461            ]
462        } else {
463            vec![
464                "Insert at least one known vector before evaluating search quality.",
465                "Verify you are querying the intended collection."
466            ]
467        }
468    }))
469    .into_response()
470}
471
472/// Delete a collection.
473#[utoipa::path(
474    delete,
475    path = "/collections/{name}",
476    tag = "collections",
477    params(
478        ("name" = String, Path, description = "Collection name")
479    ),
480    responses(
481        (status = 200, description = "Collection deleted", body = Object),
482        (status = 404, description = "Collection not found", body = ErrorResponse)
483    )
484)]
485pub async fn delete_collection(
486    State(state): State<Arc<AppState>>,
487    Path(name): Path<String>,
488) -> impl IntoResponse {
489    // Collection deletion is synchronous core code (registry locks + disk
490    // I/O) — run it on the blocking pool.
491    let state_clone = Arc::clone(&state);
492    let coll_name = name.clone();
493    match run_blocking(move || state_clone.db.delete_collection(&coll_name)).await {
494        Ok(Ok(())) => Json(serde_json::json!({
495            "message": "Collection deleted",
496            "name": name
497        }))
498        .into_response(),
499        Ok(Err(e)) => auto_core_error_response(&e),
500        Err(resp) => resp,
501    }
502}
503
504/// Check if a collection is empty.
505#[utoipa::path(
506    get,
507    path = "/collections/{name}/empty",
508    tag = "collections",
509    params(
510        ("name" = String, Path, description = "Collection name")
511    ),
512    responses(
513        (status = 200, description = "Empty status", body = Object),
514        (status = 404, description = "Collection not found", body = ErrorResponse)
515    )
516)]
517pub async fn is_empty(
518    State(state): State<Arc<AppState>>,
519    Path(name): Path<String>,
520) -> impl IntoResponse {
521    let collection = match get_collection_or_404(&state, &name) {
522        Ok(c) => c,
523        Err(resp) => return resp,
524    };
525
526    Json(serde_json::json!({
527        "is_empty": collection.is_empty()
528    }))
529    .into_response()
530}
531
532/// Flush pending changes to disk.
533#[utoipa::path(
534    post,
535    path = "/collections/{name}/flush",
536    tag = "collections",
537    params(
538        ("name" = String, Path, description = "Collection name")
539    ),
540    responses(
541        (status = 200, description = "Flushed successfully", body = Object),
542        (status = 404, description = "Collection not found", body = ErrorResponse),
543        (status = 500, description = "Flush failed", body = ErrorResponse)
544    )
545)]
546pub async fn flush_collection(
547    State(state): State<Arc<AppState>>,
548    Path(name): Path<String>,
549) -> impl IntoResponse {
550    let collection = match get_collection_or_404(&state, &name) {
551        Ok(c) => c,
552        Err(resp) => return resp,
553    };
554
555    let result = tokio::task::spawn_blocking(move || collection.flush()).await;
556    match result {
557        Ok(Ok(())) => Json(serde_json::json!({
558            "message": "Flushed successfully",
559            "collection": name
560        }))
561        .into_response(),
562        Ok(Err(e)) => auto_core_error_response(&e),
563        Err(join_err) => error_response(
564            StatusCode::INTERNAL_SERVER_ERROR,
565            format!("flush task panicked: {join_err}"),
566        ),
567    }
568}