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#[derive(Debug, Serialize, utoipa::ToSchema)]
24struct StreamDataItem {
25 pub row: serde_json::Value,
26 pub index: usize,
27 pub stmt: usize,
28}
29
30#[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#[derive(Debug, Serialize, utoipa::ToSchema)]
45struct StatementBegin {
46 pub index: usize,
47 pub query: String,
48}
49
50#[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
65struct StreamFailure {
67 code: String,
68 message: String,
69}
70
71struct StatementOutcome {
74 rows_returned: usize,
75 execution_time_ms: u64,
76 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)]
94pub 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
170type 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)]
181async 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#[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 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 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 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 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 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 match pull_handle.await {
419 Ok(PullOutcome::Completed(total_rows)) => {
420 let elapsed_us = start_time.elapsed().as_micros() as u64;
421 let metadata = StreamMetadata {
423 rows_returned: total_rows,
424 execution_time_ms: (elapsed_us / 1000),
425 columns: Vec::new(), 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 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 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 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
541enum PullOutcome {
543 Completed(usize),
544 Failed(usize, StreamFailure),
545 LimitReached(usize, usize),
546 Disconnected(usize),
547}
548
549async 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
556async 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}