Skip to content

Commit e783645

Browse files
committed
[adapters] Hoist adhoc query processing to return errors early
[web-console] Improve ad-hoc error rendering and formatting (remove message duplication) DataFusion errors raised during query setup were not reported over HTTP: the backend had already responded with status 200 and simply interrupted the stream when the error was thrown. Start the query before deciding the HTTP status. If it fails to start, respond with HTTP 400 and the error message so the client can surface it. If it starts cleanly, respond with 200 OK and stream the results as before. This adds no significant delay - only a few extra milliseconds for query planning and SQL types header generation; the initial response does not wait for the first data bytes. Signed-off-by: Karakatiza666 <bulakh.96@gmail.com>
1 parent b89fd9e commit e783645

5 files changed

Lines changed: 175 additions & 70 deletions

File tree

crates/adapters/src/adhoc.rs

Lines changed: 89 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use datafusion::prelude::*;
1010
use datafusion::sql::parser::{DFParserBuilder, Statement as DFStatement};
1111
use datafusion::sql::sqlparser::dialect::dialect_from_str;
1212
use 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
};
1616
use feldera_types::query::{AdHocResultFormat, AdhocQueryArgs, MAX_WS_FRAME_SIZE};
1717
use 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
}

crates/adapters/src/adhoc/executor.rs

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use arrow::array::RecordBatch;
2+
use arrow::datatypes::SchemaRef;
23
use arrow::ipc::convert::IpcSchemaEncoder;
34
use arrow::ipc::writer::StreamWriter;
45
use arrow::util::pretty::pretty_format_batches;
@@ -40,6 +41,31 @@ fn execute_stream(df: DataFrame) -> Receiver<DFResult<SendableRecordBatchStream>
4041
rx
4142
}
4243

44+
/// Drives physical planning and execution setup for `df`, returning the
45+
/// record-batch stream together with its schema.
46+
///
47+
/// Planning and execute-time-setup errors — e.g. selecting from a
48+
/// non-materialized table — surface here, before any result data is produced.
49+
/// Hoisting this step out of the per-format encoders lets the HTTP handler
50+
/// translate such an error into a proper 4xx response
51+
/// instead of a `200 OK` whose body is silently truncated.
52+
pub(crate) async fn execute_adhoc_stream(
53+
df: DataFrame,
54+
) -> Result<(SendableRecordBatchStream, SchemaRef), PipelineError> {
55+
let schema = df.schema().inner().clone();
56+
let stream = execute_stream(df)
57+
.await
58+
.map_err(|e| PipelineError::AdHocQueryError {
59+
error: e.to_string(),
60+
df: None,
61+
})?
62+
.map_err(|e| PipelineError::AdHocQueryError {
63+
error: e.to_string(),
64+
df: Some(Box::new(e)),
65+
})?;
66+
Ok((stream, schema))
67+
}
68+
4369
pub(crate) fn infallible_from_bytestring(
4470
fallible_stream: impl Stream<Item = Result<ByteString, PipelineError>>,
4571
map_err: impl Fn(PipelineError) -> Bytes + 'static,
@@ -53,13 +79,11 @@ pub(crate) fn infallible_from_bytestring(
5379
}
5480

5581
pub(crate) fn stream_text_query(
56-
df: DataFrame,
82+
stream: SendableRecordBatchStream,
83+
schema: SchemaRef,
5784
) -> impl Stream<Item = Result<ByteString, PipelineError>> {
58-
let schema = df.schema().inner().clone();
5985
try_stream! {
60-
let stream_executor = execute_stream(df).await.map_err(|e| PipelineError::AdHocQueryError { error: e.to_string(), df: None })?;
61-
let mut stream = stream_executor
62-
.map_err(|e| PipelineError::AdHocQueryError { error: e.to_string(), df: Some(Box::new(e)) })?;
86+
let mut stream = stream;
6387

6488
let mut headers_sent = false;
6589
let mut last_line: Option<String> = None;
@@ -159,19 +183,11 @@ impl BatchHasher {
159183
}
160184

161185
/// Computes an order-independent hash of a DataFrame's result set.
162-
pub(crate) async fn hash_query_result(df: DataFrame) -> Result<String, PipelineError> {
163-
let schema = df.schema().inner().clone();
164-
165-
let stream_executor = execute_stream(df)
166-
.await
167-
.map_err(|e| PipelineError::AdHocQueryError {
168-
error: e.to_string(),
169-
df: None,
170-
})?;
171-
let mut stream = stream_executor.map_err(|e| PipelineError::AdHocQueryError {
172-
error: e.to_string(),
173-
df: Some(Box::new(e)),
174-
})?;
186+
pub(crate) async fn hash_query_result(
187+
stream: SendableRecordBatchStream,
188+
schema: SchemaRef,
189+
) -> Result<String, PipelineError> {
190+
let mut stream = stream;
175191

176192
let mut hasher = BatchHasher::new();
177193
while let Some(batch) = stream.next().await {
@@ -182,12 +198,10 @@ pub(crate) async fn hash_query_result(df: DataFrame) -> Result<String, PipelineE
182198
}
183199

184200
pub(crate) fn stream_json_query(
185-
df: DataFrame,
201+
stream: SendableRecordBatchStream,
186202
) -> impl Stream<Item = Result<ByteString, PipelineError>> {
187203
try_stream! {
188-
let stream_executor = execute_stream(df).await.map_err(|e| PipelineError::AdHocQueryError { error: e.to_string(), df: None })?;
189-
let mut stream = stream_executor
190-
.map_err(|e| PipelineError::AdHocQueryError { error: e.to_string(), df: Some(Box::new(e)) })?;
204+
let mut stream = stream;
191205
while let Some(batch) = stream.next().await {
192206
let batch = batch.map_err(PipelineError::from)?;
193207
let mut buf = Vec::with_capacity(4096);
@@ -247,15 +261,11 @@ impl AsyncFileWriter for ChannelWriter {
247261
/// <https://github.com/apache/arrow-rs/pull/9241>. Once that lands the
248262
/// per-batch buffering here can be replaced with a direct async sink.
249263
pub(crate) fn stream_arrow_query(
250-
df: DataFrame,
264+
stream: SendableRecordBatchStream,
265+
schema: SchemaRef,
251266
) -> impl Stream<Item = Result<Bytes, DataFusionError>> {
252267
try_stream! {
253-
let schema = df.schema().inner().clone();
254-
let mut stream = execute_stream(df)
255-
.await
256-
.map_err(|e| DataFusionError::Execution(format!(
257-
"ad-hoc query worker did not return a stream: {e}"
258-
)))??;
268+
let mut stream = stream;
259269

260270
// `try_new` writes the schema message to the inner buffer. The
261271
// `BytesMut` behind `BufMut::writer()` is a single allocation
@@ -294,7 +304,8 @@ pub(crate) fn stream_arrow_query(
294304
}
295305

296306
pub(crate) fn stream_parquet_query(
297-
df: DataFrame,
307+
stream: SendableRecordBatchStream,
308+
schema: SchemaRef,
298309
) -> impl Stream<Item = Result<Bytes, DataFusionError>> {
299310
// Should probably be smaller than `MAX_WS_FRAME_SIZE`.
300311
const PARQUET_CHUNK_SIZE: usize = MAX_WS_FRAME_SIZE / 2;
@@ -304,10 +315,7 @@ pub(crate) fn stream_parquet_query(
304315

305316
let mut stream_job = Box::pin(
306317
async move {
307-
let schema = df.schema().inner().clone();
308-
let mut stream = execute_stream(df)
309-
.await
310-
.expect("unable to receive stream")?;
318+
let mut stream = stream;
311319

312320
let mut writer = AsyncArrowWriter::try_new(
313321
ChannelWriter::new(tx),
@@ -528,8 +536,11 @@ mod tests {
528536
ctx.register_table("t", Arc::new(mem)).unwrap();
529537
let df = ctx.sql("SELECT * FROM t ORDER BY id").await.unwrap();
530538

539+
let (record_stream, schema) = execute_adhoc_stream(df)
540+
.await
541+
.expect("execute_adhoc_stream failed to set up the query");
531542
let mut buf = Vec::<u8>::new();
532-
let mut stream = Box::pin(stream_arrow_query(df));
543+
let mut stream = Box::pin(stream_arrow_query(record_stream, schema));
533544
while let Some(chunk) = stream.next().await {
534545
buf.extend_from_slice(&chunk.expect("stream_arrow_query yielded an error"));
535546
}
@@ -596,8 +607,11 @@ mod tests {
596607
ctx.register_table("t", Arc::new(mem)).unwrap();
597608
let df = ctx.sql("SELECT * FROM t").await.unwrap();
598609

610+
let (record_stream, schema) = execute_adhoc_stream(df)
611+
.await
612+
.expect("execute_adhoc_stream failed to set up the query");
599613
let mut buf = Vec::<u8>::new();
600-
let mut stream = Box::pin(stream_arrow_query(df));
614+
let mut stream = Box::pin(stream_arrow_query(record_stream, schema));
601615
while let Some(chunk) = stream.next().await {
602616
buf.extend_from_slice(&chunk.expect("stream_arrow_query yielded an error"));
603617
}

js-packages/web-console/src/lib/components/adhoc/Query.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,9 @@
219219
</tr>
220220
{:else if 'error' in row}
221221
<tr {style} class={itemHeight} use:selectScope tabindex={-1}>
222-
<td colspan="99999999" class="preset-tonal-error px-2">{row.error}</td>
222+
<td colspan="99999999"
223+
><div class="rounded bg-error-50-950/50 px-2">{row.error}</div></td
224+
>
223225
</tr>
224226
{:else}
225227
<tr {style} class={itemHeight} use:selectScope tabindex={-1}>

js-packages/web-console/src/lib/services/pipelineManager.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -841,20 +841,25 @@ const getAuthenticatedFetch = (options?: FetchOptions): typeof globalThis.fetch
841841
}
842842

843843
function formatValue(details: unknown): string {
844+
if (!details) {
845+
return ''
846+
}
844847
if (typeof details === 'string') {
845848
return details
846849
}
847850

848-
// Pretty‑print objects, arrays, numbers, booleans, etc.
851+
// Pretty‑print objects, arrays, numbers, booleans, etc., dropping any
852+
// `error` field that merely duplicates the message shown above it.
849853
try {
850-
return JSON.stringify(details, null, 2)
854+
const json = JSON.stringify(details, (key, value) => (key === 'error' ? undefined : value), 2)
855+
return json === '{}' ? '' : json
851856
} catch {
852857
return String(details)
853858
}
854859
}
855860

856861
const apiErrorText = (error: ErrorResponse) => {
857-
return `${error.message}${error.details ? `\n${formatValue(error.details)}` : ''}`
862+
return `${error.message}\n${formatValue(error.details)}`
858863
}
859864

860865
const streamingFetch = (

0 commit comments

Comments
 (0)