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