Skip to content

Commit 1064661

Browse files
adriangbclaude
andauthored
fix: preserve the error type of a failing parquet row filter predicate (#24638)
## Which issue does this PR close? No existing issue tracks this; happy to file one if the project prefers that first. ## Rationale for this change `ArrowPredicate::evaluate` must return an `ArrowError`, so `DatafusionArrowPredicate::evaluate` built one by `Debug`-formatting the `DataFusionError` it received: ```rust .map_err(|e| { ArrowError::ComputeError(format!("Error evaluating filter predicate: {e:?}")) }) ``` Formatting the error discards its type. Every failure inside a predicate pushed into the parquet decoder reaches the caller as the same untyped `ArrowError::ComputeError` carrying a `Debug` string, so a user error such as a failed cast is indistinguishable from an internal engine failure. Any embedder that classifies errors by variant, for example to decide whether a query failed because of the input or because of a bug, cannot do so for this path. It also reads badly, because the nested error is rendered with `Debug` rather than `Display`. Reproduction with `datafusion-cli`: ```sql COPY (SELECT 'not_an_int' AS s) TO 't.parquet' STORED AS PARQUET; CREATE EXTERNAL TABLE t STORED AS PARQUET LOCATION 't.parquet'; SET datafusion.execution.parquet.pushdown_filters = true; SELECT * FROM t WHERE CAST(s AS INT) = 1; ``` Before: ``` Error: Parquet error: External: Compute error: Error evaluating filter predicate: ArrowError(CastError("Cannot cast string 'not_an_int' to value of Int32 type"), Some("")) ``` After: ``` Error: Parquet error: External: External error: Error evaluating filter predicate caused by Arrow error: Cast error: Cannot cast string 'not_an_int' to value of Int32 type ``` ## What changes are included in this PR? `DatafusionArrowPredicate::evaluate` now converts the error instead of formatting it: ```rust .map_err(|e| e.context("Error evaluating filter predicate").into()) ``` `From<DataFusionError> for ArrowError` is the conversion DataFusion already documents for this boundary. It leaves the original error in the `Error::source` chain, and the parquet decoder propagates it as `ParquetError::External`, which is also source preserving, so `DataFusionError::find_root` recovers the original variant at the top of the stack. Wrapping the error in a `DataFusionError::Context` first keeps the description of where the failure happened, which the old string also carried. One consequence worth calling out: because the context has to live somewhere, the returned `ArrowError` variant is `ExternalError` rather than the original Arrow variant. Callers that want the type use `find_root` (or walk `Error::source`), which is the existing way to recover an error across an `ArrowError` boundary in DataFusion and is used the same way in `datafusion/common/src/scalar/mod.rs` and `datafusion/physical-plan`. Dropping the context would yield a bare `ArrowError::CastError` here, at the cost of no longer saying which stage failed. ## Are these changes tested? Yes. Two new tests, both of which fail without the change: * `datafusion/datasource-parquet/src/row_filter.rs`: `evaluate_reports_the_original_error` evaluates a predicate that fails to cast and asserts `find_root` returns the `CastError`, and that the message still names the predicate. Without the change it observes `ArrowError(ComputeError("Error evaluating filter predicate: ArrowError(CastError(...))"))`. * `datafusion/core/tests/parquet/filter_pushdown.rs`: `pushed_down_predicate_reports_the_original_error` runs the same failure through a full parquet scan. It first asserts the predicate really is pushed into the scan and not left in a `FilterExec`, so the test cannot pass vacuously, then asserts the same about the error the query returns. Existing suites run locally: `cargo test -p datafusion-datasource-parquet`, `cargo test -p datafusion --test parquet_integration`, and the full `sqllogictest` suite, all passing. No test expectation elsewhere depended on the old string. ## Are there any user-facing changes? The text of the error raised when a pushed down parquet predicate fails changes, as shown above. There is no public API change. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 63f5b55 commit 1064661

2 files changed

Lines changed: 147 additions & 5 deletions

File tree

‎datafusion/core/tests/parquet/filter_pushdown.rs‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,27 @@
2626
//! select * from data limit 10;
2727
//! ```
2828
29+
use arrow::array::{ArrayRef, Int32Array, StringArray};
2930
use arrow::compute::concat_batches;
31+
use arrow::error::ArrowError;
3032
use arrow::record_batch::RecordBatch;
31-
use datafusion::physical_plan::collect;
3233
use datafusion::physical_plan::metrics::{MetricValue, MetricsSet};
34+
use datafusion::physical_plan::{collect, displayable};
3335
use datafusion::prelude::{
3436
Expr, ParquetReadOptions, SessionContext, col, lit, lit_timestamp_nano,
3537
};
3638
use datafusion::test_util::parquet::{ParquetScanOptions, TestParquetFile};
3739
use datafusion_expr::utils::{conjunction, disjunction, split_conjunction};
3840
use std::path::Path;
3941

42+
use datafusion_common::DataFusionError;
4043
use datafusion_common::test_util::parquet_test_data;
4144
use datafusion_execution::config::SessionConfig;
4245
use itertools::Itertools;
46+
use parquet::arrow::ArrowWriter;
4347
use parquet::file::properties::WriterProperties;
48+
use std::fs::File;
49+
use std::sync::Arc;
4450
use tempfile::TempDir;
4551

4652
/// how many rows of generated data to write to our parquet file (arbitrary)
@@ -746,3 +752,60 @@ impl PredicateCacheTest {
746752
Ok(())
747753
}
748754
}
755+
756+
/// A predicate that is pushed into the parquet decoder and then fails while it
757+
/// is being evaluated must report the original error, so that callers can still
758+
/// tell a user error apart from an internal one.
759+
#[tokio::test]
760+
async fn pushed_down_predicate_reports_the_original_error() {
761+
let tempdir = TempDir::new_in(Path::new(".")).unwrap();
762+
let path = tempdir.path().join("cast_error.parquet");
763+
764+
// `v` is never referenced by the predicate, so the projection always has a
765+
// column that only the scan can supply and a narrow-projection pushdown
766+
// heuristic has no reason to decline this scan
767+
let batch = RecordBatch::try_from_iter(vec![
768+
(
769+
"s",
770+
Arc::new(StringArray::from(vec!["not_an_int"])) as ArrayRef,
771+
),
772+
("v", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
773+
])
774+
.unwrap();
775+
let mut writer =
776+
ArrowWriter::try_new(File::create(&path).unwrap(), batch.schema(), None).unwrap();
777+
writer.write(&batch).unwrap();
778+
writer.close().unwrap();
779+
780+
let mut config = SessionConfig::new();
781+
config.options_mut().execution.parquet.pushdown_filters = true;
782+
let ctx = SessionContext::new_with_config(config);
783+
ctx.register_parquet("t", path.to_str().unwrap(), ParquetReadOptions::default())
784+
.await
785+
.unwrap();
786+
787+
// Casting the column in the file to `Int32` fails on this data
788+
let df = ctx
789+
.sql("SELECT * FROM t WHERE CAST(s AS INT) = 1")
790+
.await
791+
.unwrap();
792+
793+
let plan = df.clone().create_physical_plan().await.unwrap();
794+
let plan = displayable(plan.as_ref()).indent(false).to_string();
795+
assert!(
796+
!plan.contains("FilterExec"),
797+
"the predicate has to reach the decoder for this test to mean anything, \
798+
but a FilterExec here means pushdown was declined:\n{plan}"
799+
);
800+
801+
let err = df.collect().await.unwrap_err();
802+
let root = err.find_root();
803+
assert!(
804+
matches!(
805+
root,
806+
DataFusionError::ArrowError(inner, _)
807+
if matches!(inner.as_ref(), ArrowError::CastError(_))
808+
),
809+
"expected the original cast error, got {root:?}"
810+
);
811+
}

‎datafusion/datasource-parquet/src/row_filter.rs‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,12 @@ impl ArrowPredicate for DatafusionArrowPredicate {
166166
timer.stop();
167167
Ok(bool_arr)
168168
})
169+
// `ExternalError` is the only `ArrowError` variant that keeps a
170+
// source, and therefore the only one that leaves the original error
171+
// recoverable (see `DataFusionError::find_root`)
169172
.map_err(|e| {
170-
ArrowError::ComputeError(format!(
171-
"Error evaluating filter predicate: {e:?}"
173+
ArrowError::ExternalError(Box::new(
174+
e.context("Error evaluating filter predicate"),
172175
))
173176
})
174177
}
@@ -545,13 +548,13 @@ pub(crate) fn row_filter_from_prebuilt(
545548
mod test {
546549
use super::*;
547550
use arrow::datatypes::{DataType, Fields};
548-
use datafusion_common::ScalarValue;
551+
use datafusion_common::{DataFusionError, ScalarValue};
549552

550553
use arrow::array::{
551554
Int32Array, ListBuilder, StringArray, StringBuilder, StructArray,
552555
};
553556
use arrow::datatypes::{Field, TimeUnit::Nanosecond};
554-
use datafusion_expr::{Expr, col};
557+
use datafusion_expr::{Cast, Expr, col, lit};
555558
use datafusion_functions::core::get_field;
556559
use datafusion_functions_nested::array_has::{
557560
array_has_all_udf, array_has_any_udf, array_has_udf,
@@ -692,6 +695,82 @@ mod test {
692695
assert!(matches!(filtered, Ok(a) if a == BooleanArray::from(vec![true; 8])));
693696
}
694697

698+
/// A predicate that fails while it is being evaluated must report the
699+
/// original error, not an opaque string, so that callers can still tell a
700+
/// user error apart from an internal one.
701+
#[test]
702+
fn evaluate_reports_the_original_error() {
703+
let schema = Arc::new(Schema::new(vec![Field::new("s", DataType::Utf8, false)]));
704+
let batch = RecordBatch::try_new(
705+
Arc::clone(&schema),
706+
vec![Arc::new(StringArray::from(vec!["not_an_int"]))],
707+
)
708+
.expect("record batch");
709+
710+
let file = NamedTempFile::new().expect("temp file");
711+
let mut writer =
712+
ArrowWriter::try_new(file.reopen().unwrap(), Arc::clone(&schema), None)
713+
.expect("writer");
714+
writer.write(&batch).expect("write batch");
715+
writer.close().expect("close writer");
716+
717+
let parquet_reader_builder =
718+
ParquetRecordBatchReaderBuilder::try_new(file.reopen().expect("reopen file"))
719+
.expect("reader builder");
720+
let metadata = parquet_reader_builder.metadata().clone();
721+
let file_schema = parquet_reader_builder.schema().clone();
722+
723+
// Casting the column in the file to `Int32` fails on this data
724+
let expr = Expr::Cast(Cast::new(Box::new(col("s")), DataType::Int32)).eq(lit(1));
725+
let expr = logical2physical(&expr, &file_schema);
726+
let candidate = FilterCandidateBuilder::new(expr, Arc::clone(&file_schema))
727+
.build(&metadata)
728+
.expect("building candidate")
729+
.expect("candidate expected");
730+
731+
let mut predicate = DatafusionArrowPredicate::try_new(
732+
candidate,
733+
Count::new(),
734+
Count::new(),
735+
Time::new(),
736+
)
737+
.expect("creating filter predicate");
738+
739+
let mut parquet_reader = parquet_reader_builder
740+
.with_projection(predicate.projection().clone())
741+
.build()
742+
.expect("building reader");
743+
let first_rb = parquet_reader
744+
.next()
745+
.expect("expected record batch")
746+
.expect("expected error free record batch");
747+
748+
let err = predicate
749+
.evaluate(first_rb)
750+
.expect_err("evaluating the predicate should fail");
751+
752+
// The cast failure is still reachable, rather than being flattened into
753+
// an untyped `ArrowError::ComputeError`
754+
let err = DataFusionError::from(err);
755+
let root = err.find_root();
756+
assert!(
757+
matches!(
758+
root,
759+
DataFusionError::ArrowError(inner, _)
760+
if matches!(inner.as_ref(), ArrowError::CastError(_))
761+
),
762+
"expected the original cast error, got {root:?}"
763+
);
764+
765+
// and the message still says where the failure happened
766+
let message = err.to_string();
767+
assert!(
768+
message.contains("Error evaluating filter predicate"),
769+
"{message}"
770+
);
771+
assert!(message.contains("Cannot cast string"), "{message}");
772+
}
773+
695774
#[test]
696775
fn struct_data_structures_prevent_pushdown() {
697776
let table_schema = Arc::new(Schema::new(vec![Field::new(

0 commit comments

Comments
 (0)