Skip to main content

velesdb_server/handlers/points/
mod.rs

1//! Point operations handlers.
2
3pub mod raw;
4pub mod relations;
5pub mod streaming;
6
7pub use raw::upsert_points_raw;
8pub use relations::{get_point_relations, relate_points, set_point_ttl, unrelate_points};
9pub use streaming::{
10    __path_enable_streaming, __path_stream_insert, __path_stream_upsert_points, enable_streaming,
11    stream_insert, stream_upsert_points,
12};
13
14use axum::{
15    extract::{Path, State},
16    http::StatusCode,
17    response::IntoResponse,
18    Json,
19};
20use std::sync::Arc;
21
22use crate::types::{
23    ErrorResponse, ScrollPoint, ScrollRequest, ScrollResponse, SparseVectorInput,
24    UpsertPointsRequest,
25};
26use crate::AppState;
27use velesdb_core::api_types::serde_id;
28use velesdb_core::Point;
29
30use crate::handlers::helpers::{
31    auto_core_error_response, error_response, get_vector_collection_or_404, run_blocking,
32};
33
34use velesdb_core::index::sparse::SparseVector;
35
36/// Converts sparse vector input fields from a request into a `BTreeMap<String, SparseVector>`.
37///
38/// Merges `sparse_vector` (single, stored under `""`) and `sparse_vectors` (named map).
39/// Named map takes precedence if both provide the same key.
40fn convert_sparse_inputs(
41    sparse_vector: Option<SparseVectorInput>,
42    sparse_vectors: Option<std::collections::BTreeMap<String, SparseVectorInput>>,
43) -> Result<Option<std::collections::BTreeMap<String, SparseVector>>, String> {
44    let has_single = sparse_vector.is_some();
45    let has_named = sparse_vectors.as_ref().is_some_and(|m| !m.is_empty());
46
47    if !has_single && !has_named {
48        return Ok(None);
49    }
50
51    let mut result = std::collections::BTreeMap::new();
52
53    // Single sparse vector goes under default name ""
54    if let Some(sv_input) = sparse_vector {
55        let sv = sv_input.into_sparse_vector()?;
56        result.insert(String::new(), sv);
57    }
58
59    // Named sparse vectors (overwrite default if same key).
60    if let Some(named) = sparse_vectors {
61        merge_named_sparse_vectors(named, &mut result)?;
62    }
63
64    Ok(Some(result))
65}
66
67/// Merge a named-sparse-vector map into `result`, converting each input.
68///
69/// If both `sparse_vector` and `sparse_vectors[""]` are supplied, the named map
70/// wins; a debug trace is emitted so operators can spot this (usually
71/// unintentional) pattern.
72fn merge_named_sparse_vectors(
73    named: std::collections::BTreeMap<String, SparseVectorInput>,
74    result: &mut std::collections::BTreeMap<String, SparseVector>,
75) -> Result<(), String> {
76    for (name, sv_input) in named {
77        let sv = sv_input
78            .into_sparse_vector()
79            .map_err(|e| format!("sparse_vectors['{name}']: {e}"))?;
80        if name.is_empty() && result.contains_key("") {
81            tracing::debug!(
82                "sparse_vector (default \"\") is being overwritten by \
83                 sparse_vectors[\"\"] — supply only one to avoid ambiguity"
84            );
85        }
86        result.insert(name, sv);
87    }
88    Ok(())
89}
90
91/// Maximum number of points in a single JSON upsert request.
92///
93/// Consistent with `MAX_BULK_DELETE_SIZE` and `MAX_SCROLL_BATCH_SIZE`.
94/// The 100 MB body limit on the route already bounds bytes; this constant
95/// bounds the point *count* to prevent memory amplification from metadata-only
96/// or tiny-vector collections where 100 MB of JSON can represent millions of points.
97const MAX_UPSERT_BATCH_SIZE: usize = 100_000;
98
99/// Upsert points to a collection.
100#[utoipa::path(
101    post,
102    path = "/collections/{name}/points",
103    tag = "points",
104    params(
105        ("name" = String, Path, description = "Collection name")
106    ),
107    request_body = UpsertPointsRequest,
108    responses(
109        (status = 200, description = "Points upserted", body = Object),
110        (status = 404, description = "Collection not found", body = ErrorResponse),
111        (status = 400, description = "Invalid request or batch too large", body = ErrorResponse)
112    )
113)]
114pub async fn upsert_points(
115    State(state): State<Arc<AppState>>,
116    Path(name): Path<String>,
117    Json(req): Json<UpsertPointsRequest>,
118) -> impl IntoResponse {
119    if req.points.len() > MAX_UPSERT_BATCH_SIZE {
120        return error_response(
121            StatusCode::BAD_REQUEST,
122            format!(
123                "Batch too large: {} points (max {MAX_UPSERT_BATCH_SIZE})",
124                req.points.len()
125            ),
126        );
127    }
128
129    let collection = match get_vector_collection_or_404(&state, &name) {
130        Ok(c) => c,
131        Err(resp) => return resp,
132    };
133
134    let points = match build_points_from_request(req) {
135        Ok(p) => p,
136        Err(e) => {
137            return error_response(StatusCode::BAD_REQUEST, e);
138        }
139    };
140
141    // CRITICAL: upsert_bulk is blocking (HNSW insertion + I/O).
142    // Must use spawn_blocking to avoid blocking the async runtime.
143    let result = tokio::task::spawn_blocking(move || collection.upsert_bulk(&points)).await;
144
145    upsert_result_to_response(&state, &name, result)
146}
147
148/// Convert a `spawn_blocking` bulk-upsert result into an HTTP response.
149///
150/// On success it notifies the observer and returns `{message, count}`; a core
151/// error maps via [`auto_core_error_response`] and a task panic yields a 500.
152/// Shared by [`upsert_points`] and [`raw::upsert_points_raw`].
153pub(super) fn upsert_result_to_response(
154    state: &AppState,
155    name: &str,
156    result: Result<velesdb_core::Result<usize>, tokio::task::JoinError>,
157) -> axum::response::Response {
158    match result {
159        Ok(Ok(inserted)) => {
160            // Programmatic `Collection` upsert path; core does not fire `on_upsert`
161            // here (only via the VelesQL DML path), so this shim is the sole,
162            // non-double-counting telemetry source. See deprecation note.
163            #[allow(deprecated)]
164            state.db.notify_upsert(name, inserted);
165            Json(serde_json::json!({
166                "message": "Points upserted",
167                "count": inserted
168            }))
169            .into_response()
170        }
171        Ok(Err(e)) => auto_core_error_response(&e),
172        Err(e) => error_response(
173            StatusCode::INTERNAL_SERVER_ERROR,
174            format!("Task panicked: {e}"),
175        ),
176    }
177}
178
179/// Convert an `UpsertPointsRequest` into a `Vec<Point>`, merging sparse inputs.
180fn build_points_from_request(req: UpsertPointsRequest) -> Result<Vec<Point>, String> {
181    let mut points: Vec<Point> = Vec::with_capacity(req.points.len());
182    for p in req.points {
183        let sparse = convert_sparse_inputs(p.sparse_vector, p.sparse_vectors)?;
184        let mut point = Point::new(p.id, p.vector, p.payload);
185        point.sparse_vectors = sparse;
186        points.push(point);
187    }
188    Ok(points)
189}
190
191/// Get a point by ID.
192#[utoipa::path(
193    get,
194    path = "/collections/{name}/points/{id}",
195    tag = "points",
196    params(
197        ("name" = String, Path, description = "Collection name"),
198        ("id" = String, Path, description = "Point ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
199    ),
200    responses(
201        (status = 200, description = "Point found", body = Object),
202        (status = 404, description = "Point or collection not found", body = ErrorResponse)
203    )
204)]
205pub async fn get_point(
206    State(state): State<Arc<AppState>>,
207    Path((name, id)): Path<(String, u64)>,
208) -> impl IntoResponse {
209    let collection = match get_vector_collection_or_404(&state, &name) {
210        Ok(c) => c,
211        Err(resp) => return resp,
212    };
213
214    // Point lookup takes storage read locks — run it on the blocking pool.
215    let points = match run_blocking(move || collection.get(&[id])).await {
216        Ok(p) => p,
217        Err(resp) => return resp,
218    };
219
220    match points.into_iter().next().flatten() {
221        // ID as a string for JS precision-safety above 2^53-1, consistent with
222        // every other read surface (search/scroll/relations, see `serde_id`).
223        Some(point) => Json(serde_json::json!({
224            "id": point.id.to_string(),
225            "vector": point.vector,
226            "payload": point.payload
227        }))
228        .into_response(),
229        // PR #586 Devin fix: emit `VELES-003 PointNotFound` via
230        // `auto_core_error_response` so typed-error clients surface
231        // `PointNotFoundError` instead of a generic fallback.
232        None => auto_core_error_response(&velesdb_core::Error::PointNotFound(id)),
233    }
234}
235
236/// Delete a point by ID.
237#[utoipa::path(
238    delete,
239    path = "/collections/{name}/points/{id}",
240    tag = "points",
241    params(
242        ("name" = String, Path, description = "Collection name"),
243        ("id" = String, Path, description = "Point ID (u64 as a string; precision-safe above 2^53-1)", pattern = "^[0-9]+$")
244    ),
245    responses(
246        (status = 200, description = "Point deleted", body = Object),
247        (status = 404, description = "Point or collection not found", body = ErrorResponse)
248    )
249)]
250pub async fn delete_point(
251    State(state): State<Arc<AppState>>,
252    Path((name, id)): Path<(String, u64)>,
253) -> impl IntoResponse {
254    let collection = match get_vector_collection_or_404(&state, &name) {
255        Ok(c) => c,
256        Err(resp) => return resp,
257    };
258
259    // Deletion takes write locks and fsyncs — run it on the blocking pool.
260    match run_blocking(move || collection.delete(&[id])).await {
261        // ID as a string for JS precision-safety (see `serde_id`), consistent
262        // with the string ID accepted in the path and returned by reads.
263        Ok(Ok(())) => Json(serde_json::json!({
264            "message": "Point deleted",
265            "id": id.to_string()
266        }))
267        .into_response(),
268        Ok(Err(e)) => auto_core_error_response(&e),
269        Err(resp) => resp,
270    }
271}
272
273/// Maximum allowed batch size for scroll requests.
274const MAX_SCROLL_BATCH_SIZE: u32 = 10_000;
275
276/// Scroll through collection points with cursor-based pagination.
277#[utoipa::path(
278    post,
279    path = "/collections/{name}/points/scroll",
280    tag = "points",
281    params(("name" = String, Path, description = "Collection name")),
282    request_body = ScrollRequest,
283    responses(
284        (status = 200, description = "Scroll batch", body = ScrollResponse),
285        (status = 400, description = "Invalid request", body = ErrorResponse),
286        (status = 404, description = "Collection not found", body = ErrorResponse)
287    )
288)]
289pub async fn scroll_points(
290    State(state): State<Arc<AppState>>,
291    Path(name): Path<String>,
292    Json(req): Json<ScrollRequest>,
293) -> impl IntoResponse {
294    if req.batch_size == 0 || req.batch_size > MAX_SCROLL_BATCH_SIZE {
295        return error_response(
296            StatusCode::BAD_REQUEST,
297            "batch_size must be between 1 and 10000".to_string(),
298        );
299    }
300
301    let collection = match get_vector_collection_or_404(&state, &name) {
302        Ok(c) => c,
303        Err(resp) => return resp,
304    };
305
306    let filter = match parse_scroll_filter(&req.filter) {
307        Ok(f) => f,
308        Err(resp) => return resp,
309    };
310
311    let batch_size = req.batch_size as usize;
312    let cursor = req.cursor;
313
314    // scroll_batch is blocking (reads from storage).
315    let result = tokio::task::spawn_blocking(move || {
316        collection.scroll_batch(cursor, batch_size, filter.as_ref())
317    })
318    .await;
319
320    match result {
321        Ok(Ok(batch)) => build_scroll_response(batch),
322        Ok(Err(e)) => auto_core_error_response(&e),
323        Err(e) => error_response(
324            StatusCode::INTERNAL_SERVER_ERROR,
325            format!("Task panicked: {e}"),
326        ),
327    }
328}
329
330/// Parse the optional filter JSON into a core `Filter`.
331#[allow(clippy::result_large_err)]
332fn parse_scroll_filter(
333    filter_json: &Option<serde_json::Value>,
334) -> Result<Option<velesdb_core::Filter>, axum::response::Response> {
335    let Some(ref json) = filter_json else {
336        return Ok(None);
337    };
338    serde_json::from_value::<velesdb_core::Filter>(json.clone())
339        .map(Some)
340        .map_err(|e| error_response(StatusCode::BAD_REQUEST, format!("Invalid filter: {e}")))
341}
342
343/// Convert a core `ScrollBatch` into an HTTP JSON response.
344fn build_scroll_response(batch: velesdb_core::ScrollBatch) -> axum::response::Response {
345    let points: Vec<ScrollPoint> = batch
346        .points
347        .into_iter()
348        .map(|p| ScrollPoint {
349            id: p.id,
350            vector: p.vector,
351            payload: p.payload,
352        })
353        .collect();
354    Json(ScrollResponse {
355        next_cursor: batch.next_cursor,
356        points,
357    })
358    .into_response()
359}
360
361/// Maximum number of IDs in a single bulk delete request.
362const MAX_BULK_DELETE_SIZE: usize = 10_000;
363
364/// Request body for bulk point deletion.
365#[derive(serde::Deserialize, utoipa::ToSchema)]
366pub struct BulkDeleteRequest {
367    /// List of point IDs to delete.
368    #[serde(deserialize_with = "serde_id::deserialize_ids_from_string_or_number")]
369    #[cfg_attr(feature = "openapi", schema(schema_with = serde_id::ids_array_schema))]
370    pub ids: Vec<u64>,
371}
372
373/// Deletes multiple points by ID in a single request.
374///
375/// Accepts a JSON body with a list of point IDs. All IDs are passed to
376/// the underlying `Collection::delete(&[u64])` in one call, which is
377/// more efficient than individual deletions.
378///
379/// Returns the number of points that were requested for deletion.
380/// Points that do not exist are silently skipped (idempotent delete).
381///
382/// # Empty payload semantics
383///
384/// `{ "ids": [] }` is treated as a successful no-op: the response is
385/// `200 OK` with `deleted_count = 0`. This matches Kubernetes-style
386/// idempotent batch APIs and lets callers send empty batches without
387/// special-casing on the client side.
388///
389/// # Limits
390///
391/// Batches larger than `MAX_BULK_DELETE_SIZE` (10000) are rejected with
392/// `400 BAD_REQUEST`.
393#[utoipa::path(
394    post,
395    path = "/collections/{name}/points/delete",
396    tag = "points",
397    params(
398        ("name" = String, Path, description = "Collection name")
399    ),
400    request_body = BulkDeleteRequest,
401    responses(
402        (status = 200, description = "Points deleted", body = Object),
403        (status = 400, description = "Batch too large", body = ErrorResponse),
404        (status = 404, description = "Collection not found", body = ErrorResponse),
405        (status = 500, description = "Delete failed", body = ErrorResponse)
406    )
407)]
408pub async fn bulk_delete_points(
409    State(state): State<Arc<AppState>>,
410    Path(name): Path<String>,
411    Json(req): Json<BulkDeleteRequest>,
412) -> impl IntoResponse {
413    if req.ids.is_empty() {
414        return Json(serde_json::json!({
415            "message": "No points to delete",
416            "collection": name,
417            "deleted_count": 0
418        }))
419        .into_response();
420    }
421
422    if req.ids.len() > MAX_BULK_DELETE_SIZE {
423        return error_response(
424            StatusCode::BAD_REQUEST,
425            format!(
426                "Batch too large: {} IDs (max {MAX_BULK_DELETE_SIZE})",
427                req.ids.len()
428            ),
429        );
430    }
431
432    let collection = match get_vector_collection_or_404(&state, &name) {
433        Ok(c) => c,
434        Err(resp) => return resp,
435    };
436
437    let ids = req.ids;
438    let count = ids.len();
439    let coll_name = name.clone();
440
441    let result = tokio::task::spawn_blocking(move || collection.delete(&ids)).await;
442    match result {
443        Ok(Ok(())) => Json(serde_json::json!({
444            "message": "Points deleted",
445            "collection": coll_name,
446            "deleted_count": count
447        }))
448        .into_response(),
449        Ok(Err(e)) => auto_core_error_response(&e),
450        Err(join_err) => error_response(
451            StatusCode::INTERNAL_SERVER_ERROR,
452            format!("bulk_delete task panicked: {join_err}"),
453        ),
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn upsert_batch_constant_matches_expected_value() {
463        assert_eq!(MAX_UPSERT_BATCH_SIZE, 100_000);
464    }
465
466    #[test]
467    fn scroll_batch_constant_matches_expected_value() {
468        assert_eq!(MAX_SCROLL_BATCH_SIZE, 10_000);
469    }
470
471    #[test]
472    fn bulk_delete_batch_constant_matches_expected_value() {
473        assert_eq!(MAX_BULK_DELETE_SIZE, 10_000);
474    }
475
476    #[test]
477    fn upsert_batch_limit_is_larger_than_delete_limit() {
478        // Upsert is intentionally higher: ingestion workloads need larger batches.
479        assert!(MAX_UPSERT_BATCH_SIZE > MAX_BULK_DELETE_SIZE);
480    }
481}