Skip to main content

linkrs_server/http/handlers/
stream.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use crate::graph_service::GraphService;
5use crate::value::to_json as value_to_json;
6use axum::{
7    extract::{Extension, Json, State},
8    response::{sse::Event, Sse},
9};
10use linkrs_wire::query::StreamQueryRequest;
11use serde::Serialize;
12use serde_json::json;
13use tokio::sync::mpsc::Sender;
14use tokio_stream::wrappers::ReceiverStream;
15
16use super::query::{json_params_to_core, result_size_limit};
17use crate::http::{error::HttpError, state::AppState};
18use crate::storage::{
19    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
20};
21
22/// Streaming results data items
23#[derive(Debug, Serialize, utoipa::ToSchema)]
24struct StreamDataItem {
25    pub row: serde_json::Value,
26    pub index: usize,
27    pub stmt: usize,
28}
29
30/// Streaming results metadata
31#[derive(Debug, Serialize, utoipa::ToSchema)]
32struct StreamMetadata {
33    pub rows_returned: usize,
34    pub execution_time_ms: u64,
35    pub columns: Vec<String>,
36    pub stmt: usize,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub trace_id: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub stages: Option<linkrs_wire::query::QueryStageTimings>,
41}
42
43/// Batch-streaming boundary: a statement starts executing.
44#[derive(Debug, Serialize, utoipa::ToSchema)]
45struct StatementBegin {
46    pub index: usize,
47    pub query: String,
48}
49
50/// Batch-streaming boundary: a statement finished (successfully or not).
51#[derive(Debug, Serialize, utoipa::ToSchema)]
52struct StatementEnd {
53    pub index: usize,
54    pub success: bool,
55    pub rows_returned: usize,
56    pub execution_time_ms: u64,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub code: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub message: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub trace_id: Option<String>,
63}
64
65/// Failure detail carried by a statement outcome.
66struct StreamFailure {
67    code: String,
68    message: String,
69}
70
71/// Outcome of streaming one statement's body (schema / rows / metadata or
72/// error events are already on the channel when this returns).
73struct StatementOutcome {
74    rows_returned: usize,
75    execution_time_ms: u64,
76    /// The client disconnected mid-statement; the caller stops the loop.
77    disconnected: bool,
78    failure: Option<StreamFailure>,
79    trace_id: String,
80}
81
82type EventSender = Sender<Result<Event, HttpError>>;
83
84#[utoipa::path(
85    post,
86    path = "/v1/query/stream",
87    tag = "Stream",
88    request_body = StreamQueryRequest,
89    responses(
90        (status = 200, description = "Query result event stream", content_type = "text/event-stream"),
91        (status = 500, description = "Internal error")
92    )
93)]
94/// Execute the query and stream the results
95pub async fn execute_stream<
96    S: StorageClient
97        + StorageSchemaContextOps
98        + StorageSyncContextOps
99        + StorageOperationContextOps
100        + Clone
101        + Send
102        + Sync
103        + 'static,
104>(
105    State(state): State<AppState<S>>,
106    Extension(caller_session_id): Extension<i64>,
107    Json(request): Json<StreamQueryRequest>,
108) -> Result<
109    Sse<impl tokio_stream::Stream<Item = Result<Event, HttpError>> + Send + 'static>,
110    HttpError,
111> {
112    super::authz::require_session_owner_or_admin(&state, caller_session_id, request.session_id)?;
113    let batch_mode = !request.statements.is_empty();
114    if batch_mode && !request.query.trim().is_empty() {
115        return Err(HttpError::bad_request(
116            "Stream request must use either `query` or `statements`, not both",
117        ));
118    }
119    let buffer_capacity = request.event_buffer_capacity.clamp(1, 1000);
120    let server = state.server.clone();
121    let row_limit = result_size_limit(&state);
122
123    let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, HttpError>>(buffer_capacity);
124
125    tokio::spawn(async move {
126        let graph_service = server.get_graph_service();
127        let stats_manager = server.get_stats_manager().clone();
128        if batch_mode {
129            let parameters = json_params_to_core(&request.parameters);
130            let session_variables = json_params_to_core(&request.session_variables);
131            run_batch_stream(
132                &tx,
133                &graph_service,
134                &stats_manager,
135                request.session_id,
136                &request.statements,
137                parameters,
138                session_variables,
139                request.fail_fast,
140                row_limit,
141            )
142            .await;
143        } else {
144            let trace_id = uuid::Uuid::new_v4().to_string();
145            let outcome = stream_statement_body(
146                &tx,
147                &graph_service,
148                &stats_manager,
149                request.session_id,
150                &request.query,
151                None,
152                row_limit,
153                0,
154                trace_id,
155            )
156            .await;
157            if !outcome.disconnected {
158                send_event(&tx, "done", "{}").await;
159            }
160        }
161    });
162
163    Ok(Sse::new(ReceiverStream::new(rx)).keep_alive(
164        axum::response::sse::KeepAlive::new()
165            .interval(std::time::Duration::from_secs(10))
166            .text("keepalive"),
167    ))
168}
169
170/// Parameter bindings carried through one streamed statement:
171/// `(query parameters, session variables)`.
172type StatementBindings = (
173    Option<HashMap<String, linkrs_core::Value>>,
174    Option<HashMap<String, linkrs_core::Value>>,
175);
176
177#[allow(
178    clippy::too_many_arguments,
179    reason = "streaming handler context; grouping would obscure call sites"
180)]
181/// Batch-streaming driver: statements run sequentially in request order,
182/// each framed by `statement_begin` / `statement_end`. Every statement
183/// shares the batch request's bindings. A client disconnect cancels the
184/// running statement and skips the rest; produced statements are kept.
185async fn run_batch_stream<
186    S: StorageClient
187        + StorageSchemaContextOps
188        + StorageSyncContextOps
189        + StorageOperationContextOps
190        + Clone
191        + Send
192        + Sync
193        + 'static,
194>(
195    tx: &EventSender,
196    graph_service: &Arc<GraphService<S>>,
197    stats_manager: &Arc<linkrs_metrics::StatsManager>,
198    session_id: i64,
199    statements: &[String],
200    parameters: Option<HashMap<String, linkrs_core::Value>>,
201    session_variables: Option<HashMap<String, linkrs_core::Value>>,
202    fail_fast: bool,
203    row_limit: Option<usize>,
204) {
205    for (index, statement) in statements.iter().enumerate() {
206        let begin = StatementBegin {
207            index,
208            query: statement.clone(),
209        };
210        if !send_json(tx, "statement_begin", &begin).await {
211            return;
212        }
213        let bindings = Some((parameters.clone(), session_variables.clone()));
214        let trace_id = uuid::Uuid::new_v4().to_string();
215        let outcome = stream_statement_body(
216            tx,
217            graph_service,
218            stats_manager,
219            session_id,
220            statement,
221            bindings,
222            row_limit,
223            index,
224            trace_id,
225        )
226        .await;
227        if outcome.disconnected {
228            return;
229        }
230        let end = StatementEnd {
231            index,
232            success: outcome.failure.is_none(),
233            rows_returned: outcome.rows_returned,
234            execution_time_ms: outcome.execution_time_ms,
235            code: outcome.failure.as_ref().map(|f| f.code.clone()),
236            message: outcome.failure.as_ref().map(|f| f.message.clone()),
237            trace_id: Some(outcome.trace_id.clone()),
238        };
239        if !send_json(tx, "statement_end", &end).await {
240            return;
241        }
242        if outcome.failure.is_some() && fail_fast {
243            break;
244        }
245    }
246    send_event(tx, "done", "{}").await;
247}
248
249/// Stream one statement's schema / rows / metadata (or a single error
250/// event on failure). The terminal `done` marker and, in batch mode, the
251/// boundary events are the caller's responsibility.
252#[allow(clippy::too_many_arguments)]
253async fn stream_statement_body<
254    S: StorageClient
255        + StorageSchemaContextOps
256        + StorageSyncContextOps
257        + StorageOperationContextOps
258        + Clone
259        + Send
260        + Sync
261        + 'static,
262>(
263    tx: &EventSender,
264    graph_service: &Arc<GraphService<S>>,
265    stats_manager: &Arc<linkrs_metrics::StatsManager>,
266    session_id: i64,
267    stmt: &str,
268    bindings: Option<StatementBindings>,
269    row_limit: Option<usize>,
270    stmt_index: usize,
271    trace_id: String,
272) -> StatementOutcome {
273    let start_time = std::time::Instant::now();
274    let stmt_text = stmt.to_string();
275    let failed = |code: &str, message: String| StatementOutcome {
276        rows_returned: 0,
277        execution_time_ms: start_time.elapsed().as_millis() as u64,
278        disconnected: false,
279        failure: Some(StreamFailure {
280            code: code.to_string(),
281            message,
282        }),
283        trace_id: trace_id.clone(),
284    };
285
286    // Get a streaming result handle (chunk-at-a-time).
287    let stream_result = match bindings {
288        Some((parameters, session_variables)) => {
289            graph_service
290                .execute_stream_with_params(session_id, stmt, parameters, session_variables)
291                .await
292        }
293        None => graph_service.execute_stream(session_id, stmt).await,
294    };
295    let stream_result = match stream_result {
296        Ok(stream) => stream,
297        Err(e) => {
298            let error_msg = json!({
299                "error": true,
300                "message": e.message(),
301                "code": "QUERY_ERROR",
302                "stmt": stmt_index
303            });
304            send_event(tx, "error", &error_msg.to_string()).await;
305            let outcome = failed("QUERY_ERROR", e.message().to_string());
306            record_stream_profile(
307                stats_manager,
308                session_id,
309                &stmt_text,
310                &trace_id,
311                start_time.elapsed().as_micros() as u64,
312                0,
313                Some(e.message().to_string()),
314            );
315            return outcome;
316        }
317    };
318
319    // Send schema event BEFORE any row data, using column names from the
320    // plan (available even for empty results via the fallback mechanism).
321    let schema_sent: Arc<std::sync::atomic::AtomicBool> =
322        Arc::new(std::sync::atomic::AtomicBool::new(false));
323    if let Some(columns) = stream_result.column_names() {
324        schema_sent.store(true, std::sync::atomic::Ordering::Relaxed);
325        let schema = json!({
326            "columns": columns,
327            "column_count": columns.len(),
328            "stmt": stmt_index,
329        });
330        if let Ok(schema_str) = serde_json::to_string(&schema) {
331            send_event(tx, "schema", &schema_str).await;
332        }
333    }
334
335    // Spawn a blocking task to pull chunks synchronously
336    // and send rows through the channel.
337    let tx_pull = tx.clone();
338    let stream_result_pull = stream_result.clone();
339    let schema_sent_pull = schema_sent.clone();
340    let pull_stmt = stmt_index;
341    let pull_handle = tokio::task::spawn_blocking(move || {
342        let mut row_index: usize = 0;
343        loop {
344            match stream_result_pull.next_chunk() {
345                Ok(Some(chunk)) => {
346                    let columns = chunk.col_names();
347
348                    // Send schema event on first chunk if not already done.
349                    if !schema_sent_pull.load(std::sync::atomic::Ordering::Relaxed) {
350                        schema_sent_pull.store(true, std::sync::atomic::Ordering::Relaxed);
351                        let schema = json!({
352                            "columns": columns,
353                            "column_count": columns.len(),
354                            "stmt": pull_stmt,
355                        });
356                        if let Ok(schema_str) = serde_json::to_string(&schema) {
357                            if tx_pull
358                                .blocking_send(Ok(Event::default()
359                                    .event("schema")
360                                    .data(schema_str)))
361                                .is_err()
362                            {
363                                stream_result_pull.cancel();
364                                return PullOutcome::Disconnected(row_index);
365                            }
366                        }
367                    }
368
369                    for row in chunk.rows {
370                        if let Some(limit) = row_limit {
371                            if row_index >= limit {
372                                stream_result_pull.cancel();
373                                return PullOutcome::LimitReached(row_index, limit);
374                            }
375                        }
376                        let obj: serde_json::Map<String, serde_json::Value> = row
377                            .into_iter()
378                            .enumerate()
379                            .map(|(i, v)| {
380                                let col_name = columns.get(i).cloned().unwrap_or_default();
381                                (col_name, value_to_json(v))
382                            })
383                            .collect();
384                        let item = StreamDataItem {
385                            row: serde_json::Value::Object(obj),
386                            index: row_index,
387                            stmt: pull_stmt,
388                        };
389                        row_index += 1;
390
391                        if let Ok(data) = serde_json::to_string(&item) {
392                            if tx_pull
393                                .blocking_send(Ok(Event::default().data(data)))
394                                .is_err()
395                            {
396                                // Client disconnected — cancel the query.
397                                stream_result_pull.cancel();
398                                return PullOutcome::Disconnected(row_index);
399                            }
400                        }
401                    }
402                }
403                Ok(None) => return PullOutcome::Completed(row_index),
404                Err(e) => {
405                    return PullOutcome::Failed(
406                        row_index,
407                        StreamFailure {
408                            code: "QUERY_ERROR".to_string(),
409                            message: e.to_string(),
410                        },
411                    )
412                }
413            }
414        }
415    });
416
417    // Wait for the pull task to finish.
418    match pull_handle.await {
419        Ok(PullOutcome::Completed(total_rows)) => {
420            let elapsed_us = start_time.elapsed().as_micros() as u64;
421            // Send metadata summary AFTER all rows.
422            let metadata = StreamMetadata {
423                rows_returned: total_rows,
424                execution_time_ms: (elapsed_us / 1000),
425                columns: Vec::new(), // schema was sent upfront
426                stmt: stmt_index,
427                trace_id: Some(trace_id.clone()),
428                stages: Some(linkrs_wire::query::QueryStageTimings {
429                    parse_ms: 0.0,
430                    validate_ms: 0.0,
431                    plan_ms: 0.0,
432                    optimize_ms: 0.0,
433                    execute_ms: elapsed_us as f64 / 1000.0,
434                }),
435            };
436
437            if let Ok(meta_str) = serde_json::to_string(&metadata) {
438                send_event(tx, "metadata", &meta_str).await;
439            }
440
441            record_stream_profile(
442                stats_manager,
443                session_id,
444                &stmt_text,
445                &trace_id,
446                elapsed_us,
447                total_rows,
448                None,
449            );
450            StatementOutcome {
451                rows_returned: total_rows,
452                execution_time_ms: (elapsed_us / 1000),
453                disconnected: false,
454                failure: None,
455                trace_id: trace_id.clone(),
456            }
457        }
458        Ok(PullOutcome::Failed(total_rows, failure)) => {
459            // Execution error — send error (the caller frames it).
460            let error_msg = json!({
461                "error": true,
462                "message": failure.message,
463                "code": failure.code,
464                "stmt": stmt_index,
465            });
466            send_event(tx, "error", &error_msg.to_string()).await;
467            let elapsed_us = start_time.elapsed().as_micros() as u64;
468            let message = failure.message.clone();
469            record_stream_profile(
470                stats_manager,
471                session_id,
472                &stmt_text,
473                &trace_id,
474                elapsed_us,
475                total_rows,
476                Some(message),
477            );
478            StatementOutcome {
479                rows_returned: total_rows,
480                execution_time_ms: start_time.elapsed().as_millis() as u64,
481                disconnected: false,
482                failure: Some(failure),
483                trace_id: trace_id.clone(),
484            }
485        }
486        Ok(PullOutcome::LimitReached(total_rows, limit)) => {
487            // Row ceiling hit — send a truncation error (the caller frames
488            // it); rows already delivered are kept.
489            let failure = StreamFailure {
490                code: "ROW_LIMIT_EXCEEDED".to_string(),
491                message: format!(
492                    "Result size limit of {limit} rows reached; narrow the query with LIMIT or use server export"
493                ),
494            };
495            let error_msg = json!({
496                "error": true,
497                "message": failure.message,
498                "code": failure.code,
499                "stmt": stmt_index,
500            });
501            send_event(tx, "error", &error_msg.to_string()).await;
502            let elapsed_us = start_time.elapsed().as_micros() as u64;
503            let message = failure.message.clone();
504            record_stream_profile(
505                stats_manager,
506                session_id,
507                &stmt_text,
508                &trace_id,
509                elapsed_us,
510                total_rows,
511                Some(message),
512            );
513            StatementOutcome {
514                rows_returned: total_rows,
515                execution_time_ms: start_time.elapsed().as_millis() as u64,
516                disconnected: false,
517                failure: Some(failure),
518                trace_id: trace_id.clone(),
519            }
520        }
521        Ok(PullOutcome::Disconnected(total_rows)) => StatementOutcome {
522            rows_returned: total_rows,
523            execution_time_ms: start_time.elapsed().as_millis() as u64,
524            disconnected: true,
525            failure: None,
526            trace_id: trace_id.clone(),
527        },
528        Err(_) => {
529            // Task panicked or cancelled — channel will be dropped.
530            StatementOutcome {
531                rows_returned: 0,
532                execution_time_ms: start_time.elapsed().as_millis() as u64,
533                disconnected: true,
534                failure: None,
535                trace_id: trace_id.clone(),
536            }
537        }
538    }
539}
540
541/// Chunk-pull loop outcome from the blocking task.
542enum PullOutcome {
543    Completed(usize),
544    Failed(usize, StreamFailure),
545    LimitReached(usize, usize),
546    Disconnected(usize),
547}
548
549/// Send a raw string payload; returns false when the client is gone.
550async fn send_event(tx: &EventSender, event: &str, data: &str) -> bool {
551    tx.send(Ok(Event::default().event(event).data(data)))
552        .await
553        .is_ok()
554}
555
556/// Serialize a payload and send it; returns false when the client is gone.
557async fn send_json<T: Serialize>(tx: &EventSender, event: &str, payload: &T) -> bool {
558    match serde_json::to_string(payload) {
559        Ok(data) => send_event(tx, event, &data).await,
560        Err(_) => false,
561    }
562}
563
564fn record_stream_profile(
565    stats_manager: &Arc<linkrs_metrics::StatsManager>,
566    session_id: i64,
567    query_text: &str,
568    trace_id: &str,
569    elapsed_us: u64,
570    rows: usize,
571    error: Option<String>,
572) {
573    if let Some(message) = error {
574        let mut profile = linkrs_metrics::QueryProfile::new(session_id, query_text.to_string());
575        profile.trace_id = trace_id.to_string();
576        profile.total_duration_us = elapsed_us;
577        profile.stages.execute_us = elapsed_us;
578        profile.result_count = rows;
579        let mut metrics = linkrs_metrics::QueryMetrics::new();
580        metrics.execute_time_us = elapsed_us;
581        metrics.total_time_us = elapsed_us;
582        metrics.result_row_count = rows;
583        stats_manager.record_query_metrics(&metrics);
584        let error_info = linkrs_metrics::ErrorInfo::new(
585            linkrs_metrics::ErrorType::ExecutionError,
586            linkrs_metrics::QueryPhase::Execute,
587            message,
588        );
589        stats_manager.record_failed_query(profile, error_info);
590    } else {
591        let mut profile = linkrs_metrics::QueryProfile::new(session_id, query_text.to_string());
592        profile.trace_id = trace_id.to_string();
593        profile.total_duration_us = elapsed_us;
594        profile.stages.execute_us = elapsed_us;
595        profile.result_count = rows;
596        let mut metrics = linkrs_metrics::QueryMetrics::new();
597        metrics.execute_time_us = elapsed_us;
598        metrics.total_time_us = elapsed_us;
599        metrics.result_row_count = rows;
600        stats_manager.record_query_metrics(&metrics);
601        stats_manager.record_query_profile(profile);
602    }
603}