velesdb_server/handlers/points/
streaming.rs1use 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#[derive(Default)]
25struct StreamUpsertStats {
26 inserted: usize,
27 malformed: usize,
28 failed_upserts: usize,
29 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 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
65async 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 #[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 #[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#[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 #[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
174fn 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
181async 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 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
217fn 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
231fn 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#[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#[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
299fn 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#[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#[cfg(feature = "persistence")]
378fn enable_streaming_on(collection: &VectorCollection, req: &EnableStreamingRequest) {
379 collection.enable_streaming(req.to_config());
380}
381
382#[cfg(not(feature = "persistence"))]
384fn enable_streaming_on(_collection: &VectorCollection, _req: &EnableStreamingRequest) {}