@@ -10,8 +10,8 @@ use datafusion::prelude::*;
1010use datafusion:: sql:: parser:: { DFParserBuilder , Statement as DFStatement } ;
1111use datafusion:: sql:: sqlparser:: dialect:: dialect_from_str;
1212use executor:: {
13- hash_query_result, infallible_from_bytestring, stream_arrow_query, stream_json_query ,
14- stream_parquet_query, stream_text_query,
13+ execute_adhoc_stream , hash_query_result, infallible_from_bytestring, stream_arrow_query,
14+ stream_json_query , stream_parquet_query, stream_text_query,
1515} ;
1616use feldera_types:: query:: { AdHocResultFormat , AdhocQueryArgs , MAX_WS_FRAME_SIZE } ;
1717use futures_util:: StreamExt ;
@@ -46,9 +46,24 @@ async fn adhoc_query_handler(
4646 mut ws_session : WsSession ,
4747 args : AdhocQueryArgs ,
4848) -> Result < ( ) , Closed > {
49+ // Set up execution before encoding. Planning and execute-time-setup
50+ // errors (e.g. selecting from a non-materialized source) surface here;
51+ // report them on the websocket and close, matching the per-format error
52+ // handling below.
53+ let ( record_stream, schema) = match execute_adhoc_stream ( df) . await {
54+ Ok ( stream_and_schema) => stream_and_schema,
55+ Err ( e) => {
56+ ws_session
57+ . text ( serde_json:: to_string ( & e) . unwrap_or_else ( |_| e. to_string ( ) ) )
58+ . await ?;
59+ ws_close ( ws_session, CloseCode :: Error ) . await ;
60+ return Ok ( ( ) ) ;
61+ }
62+ } ;
63+
4964 match args. format {
5065 AdHocResultFormat :: Text => {
51- let mut stream = Box :: pin ( stream_text_query ( df ) ) ;
66+ let mut stream = Box :: pin ( stream_text_query ( record_stream , schema ) ) ;
5267 while let Some ( res) = stream. next ( ) . await {
5368 match res {
5469 Ok ( text) => {
@@ -63,7 +78,7 @@ async fn adhoc_query_handler(
6378 }
6479 }
6580 AdHocResultFormat :: Json => {
66- let mut stream = Box :: pin ( stream_json_query ( df ) ) ;
81+ let mut stream = Box :: pin ( stream_json_query ( record_stream ) ) ;
6782 while let Some ( res) = stream. next ( ) . await {
6883 match res {
6984 Ok ( byte_string) => {
@@ -80,7 +95,7 @@ async fn adhoc_query_handler(
8095 }
8196 }
8297 AdHocResultFormat :: ArrowIpc => {
83- let mut stream = Box :: pin ( stream_arrow_query ( df ) ) ;
98+ let mut stream = Box :: pin ( stream_arrow_query ( record_stream , schema ) ) ;
8499 while let Some ( res) = stream. next ( ) . await {
85100 match res {
86101 Ok ( bytes) => {
@@ -103,7 +118,7 @@ async fn adhoc_query_handler(
103118 }
104119 }
105120 AdHocResultFormat :: Parquet => {
106- let mut stream = Box :: pin ( stream_parquet_query ( df ) ) ;
121+ let mut stream = Box :: pin ( stream_parquet_query ( record_stream , schema ) ) ;
107122 while let Some ( res) = stream. next ( ) . await {
108123 match res {
109124 Ok ( bytes) => ws_session. binary ( bytes) . await ?,
@@ -124,7 +139,7 @@ async fn adhoc_query_handler(
124139 }
125140 }
126141 AdHocResultFormat :: Hash => {
127- let hash_result = hash_query_result ( df ) . await ;
142+ let hash_result = hash_query_result ( record_stream , schema ) . await ;
128143 match hash_result {
129144 Ok ( hash) => {
130145 ws_session. text ( hash) . await ?;
@@ -445,28 +460,33 @@ pub(crate) async fn stream_adhoc_result(
445460) -> Result < HttpResponse , PipelineError > {
446461 let df = execute_sql ( controller, & args. sql ) . await ?;
447462
448- // Note that once we are in the stream!{} macros any error that occurs will lead to the connection
449- // in the manager being terminated and a 500 error being returned to the client.
450- // We can't return an error in a stream that is already Response::Ok.
451- //
452- // Sometimes things do tend to fail inside the stream!{} macro, e.g., "select 1/0;" will cause a
453- // division by zero error during query execution. So we return errors according to the chosen
454- // format for text and json, and for parquet we return the 500 error.
463+ // Set up execution before committing a response status. Planning and
464+ // execute-time-setup errors — e,g, selecting from a non-materialized source —
465+ // surface here and are returned as a regular `PipelineError` (HTTP 400),
466+ // so the client sees the message rather than a truncated `200 OK` body.
467+ let ( record_stream, schema) = execute_adhoc_stream ( df) . await ?;
468+
469+ // Once we hand a stream to `.streaming(...)` the `200 OK` status is
470+ // already yielded, so an error raised *mid-stream* (e.g. "select
471+ // 1/0;" failing on a later row) can no longer change the status. For
472+ // text and json we fold such errors into the response body; for arrow
473+ // and parquet the body is simply terminated (and the manager surfaces a 500).
455474 match args. format {
456475 AdHocResultFormat :: Text => Ok ( HttpResponse :: Ok ( )
457476 . content_type ( mime:: TEXT_PLAIN )
458- . streaming :: < _ , Infallible > ( infallible_from_bytestring ( stream_text_query ( df) , |e| {
459- format ! ( "ERROR: {}" , e) . into ( )
460- } ) ) ) ,
477+ . streaming :: < _ , Infallible > ( infallible_from_bytestring (
478+ stream_text_query ( record_stream, schema) ,
479+ |e| format ! ( "ERROR: {}" , e) . into ( ) ,
480+ ) ) ) ,
461481 AdHocResultFormat :: Json => Ok ( HttpResponse :: Ok ( )
462482 . content_type ( mime:: APPLICATION_JSON )
463483 . streaming :: < _ , Infallible > ( infallible_from_bytestring (
464- stream_json_query ( df ) ,
484+ stream_json_query ( record_stream ) ,
465485 |e| serde_json:: to_string ( & e) . unwrap ( ) . into ( ) ,
466486 ) ) ) ,
467487 AdHocResultFormat :: ArrowIpc => Ok ( HttpResponse :: Ok ( )
468488 . content_type ( mime:: APPLICATION_OCTET_STREAM )
469- . streaming ( stream_arrow_query ( df ) ) ) ,
489+ . streaming ( stream_arrow_query ( record_stream , schema ) ) ) ,
470490 AdHocResultFormat :: Parquet => {
471491 let file_name = format ! (
472492 "results_{}.parquet" ,
@@ -475,11 +495,11 @@ pub(crate) async fn stream_adhoc_result(
475495 Ok ( HttpResponse :: Ok ( )
476496 . insert_header ( header:: ContentDisposition :: attachment ( file_name) )
477497 . content_type ( mime:: APPLICATION_OCTET_STREAM )
478- . streaming ( stream_parquet_query ( df ) ) )
498+ . streaming ( stream_parquet_query ( record_stream , schema ) ) )
479499 }
480500 AdHocResultFormat :: Hash => Ok ( HttpResponse :: Ok ( )
481501 . content_type ( mime:: TEXT_PLAIN )
482- . body ( hash_query_result ( df ) . await ?) ) ,
502+ . body ( hash_query_result ( record_stream , schema ) . await ?) ) ,
483503 }
484504}
485505
@@ -684,4 +704,52 @@ mod tests {
684704 let total_rows: usize = batches. iter ( ) . map ( |b| b. num_rows ( ) ) . sum ( ) ;
685705 assert_eq ! ( total_rows, 1 ) ;
686706 }
707+
708+ /// Selecting from a non-materialized source fails during *execution
709+ /// setup*, not planning. `execute_adhoc_stream` must surface that as an
710+ /// `Err`, letting the HTTP handler return a non-2xx status before
711+ /// committing a `200 OK` whose body would otherwise be silently
712+ /// truncated. This is what gives the HTTP transport the same up-front
713+ /// error visibility the WebSocket transport already has.
714+ #[ tokio:: test]
715+ async fn non_materialized_select_surfaces_error_before_streaming ( ) {
716+ use crate :: adhoc:: table:: AdHocTable ;
717+ use datafusion:: arrow:: datatypes:: { DataType , Field , Schema } ;
718+ use feldera_types:: program_schema:: SqlIdentifier ;
719+ use std:: collections:: BTreeMap ;
720+ use std:: sync:: Weak ;
721+
722+ let schema = Arc :: new ( Schema :: new ( vec ! [ Field :: new( "x" , DataType :: Int64 , false ) ] ) ) ;
723+ let ctx = SessionContext :: new_with_state ( test_state ( ) ) ;
724+
725+ // A view (no input handle) that is *not* materialized.
726+ let table = Arc :: new ( AdHocTable :: new (
727+ false , // materialized
728+ false , // indexed
729+ Weak :: new ( ) ,
730+ None , // input_handle: view, not a base table
731+ SqlIdentifier :: new ( "v" , false ) ,
732+ schema,
733+ ) ) ;
734+ ctx. register_table ( "v" , table) . unwrap ( ) ;
735+
736+ // The scan reads a consistent snapshot from the session config; an
737+ // empty one suffices because execution fails the materialization
738+ // check before touching any data.
739+ let mut state = ctx. state ( ) ;
740+ set_snapshot ( & mut state, Arc :: new ( BTreeMap :: new ( ) ) ) ;
741+
742+ // Planning succeeds: the materialization check lives in physical
743+ // execution setup, not planning.
744+ let df = execute_sql_with_state ( state, "SELECT * FROM v" )
745+ . await
746+ . expect ( "planning a select over a non-materialized view should succeed" ) ;
747+
748+ // The error must surface here, before any response status is set.
749+ let msg = match execute_adhoc_stream ( df) . await {
750+ Ok ( _) => panic ! ( "selecting from a non-materialized source must fail at setup" ) ,
751+ Err ( e) => format ! ( "{e}" ) ,
752+ } ;
753+ assert ! ( msg. contains( "non-materialized" ) , "unexpected error: {msg}" ) ;
754+ }
687755}
0 commit comments