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