Skip to main content

velesdb_server/handlers/points/
streaming.rs

1//! NDJSON streaming upsert and bounded ingestion channel handlers.
2
3use axum::{
4    body::Body,
5    extract::{Path, State},
6    http::StatusCode,
7    response::IntoResponse,
8    Json,
9};
10use futures::StreamExt;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use crate::types::{EnableStreamingRequest, ErrorResponse, StreamInsertRequest};
15use crate::AppState;
16use velesdb_core::{BackpressureError, Point, VectorCollection};
17
18use crate::handlers::helpers::{error_response, get_vector_collection_or_404};
19
20const STREAM_BATCH_SIZE: usize = 100;
21const STREAM_BATCH_MAX_WAIT: Duration = Duration::from_millis(100);
22
23/// Accumulates statistics over an NDJSON stream upsert operation.
24#[derive(Default)]
25struct StreamUpsertStats {
26    inserted: usize,
27    malformed: usize,
28    failed_upserts: usize,
29    /// Number of HTTP/transport errors encountered while reading the request body.
30    ///
31    /// A non-zero value means the stream was truncated mid-transfer; the response
32    /// `inserted` count is therefore a lower bound on how many points were actually
33    /// sent by the client.
34    network_errors: u64,
35}
36
37fn parse_ndjson_line(
38    line: &str,
39    batch: &mut Vec<Point>,
40    stats: &mut StreamUpsertStats,
41    point_id_hint: Option<u64>,
42) {
43    if line.is_empty() {
44        return;
45    }
46
47    match serde_json::from_str::<Point>(line) {
48        Ok(point) => batch.push(point),
49        Err(error) => {
50            stats.malformed += 1;
51            // N-2: include point ID in the warning when it is available from context.
52            if let Some(id) = point_id_hint {
53                tracing::warn!(
54                    error = %error,
55                    point_id = id,
56                    "Skipping malformed NDJSON point"
57                );
58            } else {
59                tracing::warn!(error = %error, "Skipping malformed NDJSON point");
60            }
61        }
62    }
63}
64
65/// Flushes a batch and -- if the collection's delta buffer is active -- also
66/// pushes the entries into the buffer for immediate searchability.
67async fn flush_point_batch_with_delta(
68    collection: &VectorCollection,
69    batch: &mut Vec<Point>,
70    stats: &mut StreamUpsertStats,
71) {
72    if batch.is_empty() {
73        return;
74    }
75
76    let points = std::mem::take(batch);
77    let batch_size = points.len();
78
79    // Snapshot (id, vector) pairs for delta before moving `points` into spawn_blocking.
80    // Only allocate when delta is active to keep the hot path allocation-free.
81    #[cfg(feature = "persistence")]
82    let delta_entries: Vec<(u64, Vec<f32>)> = if collection.is_delta_active() {
83        points.iter().map(|p| (p.id, p.vector.clone())).collect()
84    } else {
85        Vec::new()
86    };
87
88    let coll = collection.clone();
89    match tokio::task::spawn_blocking(move || coll.upsert_bulk(&points)).await {
90        Ok(Ok(inserted)) => {
91            stats.inserted += inserted;
92
93            // C-3: push into the delta buffer after a successful upsert so that
94            // search can find these points before HNSW is rebuilt.
95            #[cfg(feature = "persistence")]
96            if !delta_entries.is_empty() {
97                collection.push_to_delta_if_active(&delta_entries);
98            }
99        }
100        Ok(Err(error)) => {
101            stats.failed_upserts += batch_size;
102            tracing::error!(
103                error = %error,
104                batch_size,
105                "Failed to upsert streamed batch"
106            );
107        }
108        Err(error) => {
109            stats.failed_upserts += batch_size;
110            tracing::error!(
111                error = %error,
112                batch_size,
113                "Stream upsert batch task panicked"
114            );
115        }
116    }
117}
118
119/// Stream upsert points using NDJSON.
120///
121/// Accepts a `application/x-ndjson` body. Each line is one JSON-encoded point,
122/// whose `id` is an unsigned 64-bit integer: a line that is not blank and does
123/// not parse as one, a string `id` included, is counted in the response's
124/// `malformed` field.
125/// Points are accumulated into micro-batches and flushed via `upsert_bulk`.
126///
127/// The response body includes a `network_errors` field: a non-zero value means
128/// the HTTP body stream was truncated (e.g., client disconnect or proxy error),
129/// and the server may have received fewer points than the client sent (M-7).
130#[utoipa::path(
131    post,
132    path = "/collections/{name}/points/stream",
133    tag = "points",
134    params(
135        ("name" = String, Path, description = "Collection name")
136    ),
137    request_body(content = String, content_type = "application/x-ndjson", description = "NDJSON stream with one point per line"),
138    responses(
139        (status = 200, description = "Stream processed", body = Object),
140        (status = 404, description = "Collection not found", body = ErrorResponse)
141    )
142)]
143pub async fn stream_upsert_points(
144    State(state): State<Arc<AppState>>,
145    Path(name): Path<String>,
146    body: Body,
147) -> impl IntoResponse {
148    let collection = match get_vector_collection_or_404(&state, &name) {
149        Ok(c) => c,
150        Err(resp) => return resp,
151    };
152
153    let stats = process_ndjson_stream(&collection, body).await;
154
155    if stats.inserted > 0 {
156        // This REST path writes via the programmatic `Collection` API, which core
157        // does NOT instrument (core fires `on_upsert` only through the VelesQL DML
158        // use-case path). The shim is therefore the sole telemetry source here and
159        // does not double-count. See `Database::notify_upsert` deprecation note.
160        #[allow(deprecated)]
161        state.db.notify_upsert(&name, stats.inserted);
162    }
163
164    Json(serde_json::json!({
165        "message": "Stream processed",
166        "inserted": stats.inserted,
167        "malformed": stats.malformed,
168        "failed_upserts": stats.failed_upserts,
169        "network_errors": stats.network_errors
170    }))
171    .into_response()
172}
173
174/// Pre-parse the `id` field from a JSON line for diagnostic logging.
175fn extract_id_hint(line: &str) -> Option<u64> {
176    serde_json::from_str::<serde_json::Value>(line)
177        .ok()
178        .and_then(|v| v.get("id").and_then(|id| id.as_u64()))
179}
180
181/// Read an NDJSON body stream, batching points and flushing periodically.
182async fn process_ndjson_stream(collection: &VectorCollection, body: Body) -> StreamUpsertStats {
183    let mut stream = body.into_data_stream();
184    let mut buffer = Vec::<u8>::new();
185    let mut batch = Vec::with_capacity(STREAM_BATCH_SIZE);
186    let mut stats = StreamUpsertStats::default();
187    let mut last_flush = Instant::now();
188
189    while let Some(chunk_result) = stream.next().await {
190        match chunk_result {
191            Ok(chunk) => {
192                buffer.extend_from_slice(&chunk);
193                process_buffer_lines(&mut buffer, &mut batch, &mut stats);
194                if should_flush(&batch, last_flush) {
195                    flush_point_batch_with_delta(collection, &mut batch, &mut stats).await;
196                    last_flush = Instant::now();
197                }
198            }
199            Err(error) => {
200                stats.network_errors += 1;
201                tracing::warn!(error = %error, "Error while reading request body stream");
202            }
203        }
204    }
205
206    // Drain any remaining incomplete line in the buffer.
207    if !buffer.is_empty() {
208        let line = String::from_utf8_lossy(&buffer);
209        let id_hint = extract_id_hint(line.trim());
210        parse_ndjson_line(line.trim(), &mut batch, &mut stats, id_hint);
211    }
212
213    flush_point_batch_with_delta(collection, &mut batch, &mut stats).await;
214    stats
215}
216
217/// Extract complete lines from the byte buffer and parse them as NDJSON points.
218fn process_buffer_lines(
219    buffer: &mut Vec<u8>,
220    batch: &mut Vec<Point>,
221    stats: &mut StreamUpsertStats,
222) {
223    while let Some(newline_pos) = buffer.iter().position(|byte| *byte == b'\n') {
224        let line_bytes: Vec<u8> = buffer.drain(..=newline_pos).collect();
225        let line = String::from_utf8_lossy(&line_bytes);
226        let id_hint = extract_id_hint(line.trim());
227        parse_ndjson_line(line.trim(), batch, stats, id_hint);
228    }
229}
230
231/// Check whether the current batch should be flushed.
232fn should_flush(batch: &[Point], last_flush: Instant) -> bool {
233    batch.len() >= STREAM_BATCH_SIZE
234        || (!batch.is_empty() && last_flush.elapsed() >= STREAM_BATCH_MAX_WAIT)
235}
236
237/// Stream-insert a single point via the bounded ingestion channel.
238///
239/// Returns 202 Accepted on success, 429 Too Many Requests when the buffer is
240/// full (with `Retry-After: 1` header per RFC 7231), 503 Service Unavailable
241/// when the drain task has exited, and 404 when the collection is not found.
242///
243/// This handler is `async` to satisfy Axum's handler contract; it does not
244/// perform any async I/O internally (the channel send is non-blocking).
245#[utoipa::path(
246    post,
247    path = "/collections/{name}/stream/insert",
248    tag = "points",
249    params(
250        ("name" = String, Path, description = "Collection name")
251    ),
252    request_body = StreamInsertRequest,
253    responses(
254        (status = 202, description = "Point accepted into streaming buffer"),
255        (status = 429, description = "Streaming buffer full — retry after 1 second", body = ErrorResponse),
256        (status = 503, description = "Streaming drain task has exited — collection must be reconfigured", body = ErrorResponse),
257        (status = 404, description = "Collection not found", body = ErrorResponse),
258        (status = 409, description = "Streaming not configured", body = ErrorResponse),
259        (status = 422, response = crate::types::MalformedBody)
260    )
261)]
262pub async fn stream_insert(
263    State(state): State<Arc<AppState>>,
264    Path(name): Path<String>,
265    Json(req): Json<StreamInsertRequest>,
266) -> impl IntoResponse {
267    let collection = match get_vector_collection_or_404(&state, &name) {
268        Ok(c) => c,
269        Err(resp) => return resp,
270    };
271
272    if let Err(resp) = validate_stream_dimension(&collection, &req) {
273        return resp;
274    }
275
276    let point = Point::new(req.id, req.vector, req.payload);
277    stream_insert_result_to_response(collection.stream_insert(point))
278}
279
280/// Validate that the request vector dimension matches the collection.
281#[allow(clippy::result_large_err)]
282fn validate_stream_dimension(
283    collection: &VectorCollection,
284    req: &StreamInsertRequest,
285) -> Result<(), axum::response::Response> {
286    let expected_dim = collection.dimension();
287    if req.vector.len() != expected_dim {
288        return Err(error_response(
289            StatusCode::BAD_REQUEST,
290            format!(
291                "Vector dimension mismatch: collection expects {expected_dim}, got {}",
292                req.vector.len()
293            ),
294        ));
295    }
296    Ok(())
297}
298
299/// Convert a `stream_insert` result into an HTTP response.
300fn stream_insert_result_to_response(
301    result: Result<(), BackpressureError>,
302) -> axum::response::Response {
303    match result {
304        Ok(()) => StatusCode::ACCEPTED.into_response(),
305        Err(BackpressureError::BufferFull) => {
306            let mut headers = axum::http::HeaderMap::new();
307            headers.insert("Retry-After", axum::http::HeaderValue::from_static("1"));
308            (
309                StatusCode::TOO_MANY_REQUESTS,
310                headers,
311                Json(ErrorResponse {
312                    error: "Stream buffer full, retry after 1s".to_string(),
313                    code: None,
314                }),
315            )
316                .into_response()
317        }
318        Err(BackpressureError::DrainTaskDead) => error_response(
319            StatusCode::SERVICE_UNAVAILABLE,
320            "Streaming drain task has exited; the collection must be reconfigured".to_string(),
321        ),
322        Err(BackpressureError::NotConfigured) => error_response(
323            StatusCode::CONFLICT,
324            "Streaming not configured for this collection".to_string(),
325        ),
326        Err(e) => error_response(
327            StatusCode::INTERNAL_SERVER_ERROR,
328            format!("Unexpected streaming error: {e}"),
329        ),
330    }
331}
332
333/// Enable streaming ingestion on a collection.
334///
335/// Spawns the background drain task so subsequent calls to
336/// `/collections/{name}/stream/insert` accept points instead of returning a
337/// `409 Conflict` "streaming not configured" error. Omitted body fields fall
338/// back to the engine defaults. Calling this again replaces the existing
339/// ingester (the old drain task is aborted).
340///
341/// Returns `200 OK` on success and `404 Not Found` when the collection does
342/// not exist.
343#[utoipa::path(
344    post,
345    path = "/collections/{name}/stream/enable",
346    tag = "points",
347    params(
348        ("name" = String, Path, description = "Collection name")
349    ),
350    request_body = EnableStreamingRequest,
351    responses(
352        (status = 200, description = "Streaming enabled", body = Object),
353        (status = 404, description = "Collection not found", body = ErrorResponse),
354        (status = 422, response = crate::types::MalformedBody)
355    )
356)]
357pub async fn enable_streaming(
358    State(state): State<Arc<AppState>>,
359    Path(name): Path<String>,
360    Json(req): Json<EnableStreamingRequest>,
361) -> impl IntoResponse {
362    let collection = match get_vector_collection_or_404(&state, &name) {
363        Ok(c) => c,
364        Err(resp) => return resp,
365    };
366
367    enable_streaming_on(&collection, &req);
368
369    Json(serde_json::json!({
370        "message": "Streaming enabled",
371        "collection": name,
372    }))
373    .into_response()
374}
375
376/// Enables streaming on the collection when persistence is compiled in.
377#[cfg(feature = "persistence")]
378fn enable_streaming_on(collection: &VectorCollection, req: &EnableStreamingRequest) {
379    collection.enable_streaming(req.to_config());
380}
381
382/// No-op when persistence is disabled (the streaming pipeline is unavailable).
383#[cfg(not(feature = "persistence"))]
384fn enable_streaming_on(_collection: &VectorCollection, _req: &EnableStreamingRequest) {}