fix: preserve the error type of a failing parquet row filter predicate - #24638
Conversation
`DatafusionArrowPredicate::evaluate` must return an `ArrowError`, and it built one by `Debug`-formatting the `DataFusionError` into an `ArrowError::ComputeError`. Every failure inside a predicate pushed into the parquet decoder therefore arrived as the same untyped string, so a caller could no longer tell a user error such as a failed cast from an internal engine failure. Convert the error instead of formatting it. `From<DataFusionError> for ArrowError` leaves the original error in the source chain, so `DataFusionError::find_root` recovers the original variant, and wrapping it in a `DataFusionError::Context` first keeps the description of where the failure happened.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #24638 +/- ##
========================================
Coverage 81.43% 81.44%
========================================
Files 1118 1118
Lines 399414 399602 +188
Branches 399414 399602 +188
========================================
+ Hits 325278 325450 +172
- Misses 55145 55153 +8
- Partials 18991 18999 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
saadtajwar
left a comment
There was a problem hiding this comment.
Not a maintainer - but this change makes sense and LGTM!
My only nit feedback is the comment itself here may be unnecessary/redundant, but that's just a personal preference :)
// Convert rather than format the error: the decoder requires an
// `ArrowError`, and formatting collapses every failure into one
// untyped `ComputeError`. `From<DataFusionError> for ArrowError`
// leaves the original error in the source chain, so callers can
// still recover it (for example with `DataFusionError::find_root`).
|
@zhuqi-lucas wonder if you could take a look at this since you've also been working on row filters lately? |
Keep the part that is not obvious from the code (converting preserves the source chain) and drop the restatement of what the old code did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zhuqi-lucas
left a comment
There was a problem hiding this comment.
Nice catch — format!("{e:?}") was flattening the type and rendering the nested error with Debug, so this is a clear improvement.
I traced the chain against common/src/error.rs and it holds: context (:525) → From<DataFusionError> for ArrowError (:368) → find_root (:502) recovers ArrowError::CastError. I also grepped the whole repo (including .slt) for the old string — only this PR's two files match, so nothing else pinned it. Both tests look non-vacuous.
Two comments inline, neither blocking.
| // Convert rather than format: converting leaves the original error | ||
| // in the source chain, so callers can still recover it (for example | ||
| // with `DataFusionError::find_root`) | ||
| .map_err(|e| e.context("Error evaluating filter predicate").into()) |
There was a problem hiding this comment.
Worth making explicit which trade this is, since From<DataFusionError> for ArrowError has an arm built for exactly this case:
// common/src/error.rs:370
match e {
DataFusionError::ArrowError(e, _) => *e, // preserves the original variant
DataFusionError::External(e) => ArrowError::ExternalError(e),
other => ArrowError::ExternalError(Box::new(other)), // <- .context() lands here
}.context() wraps into DataFusionError::Context first, which is precisely what routes past that first arm — so a plain .map_err(|e| e.into()) would hand back a real ArrowError::CastError at this boundary, while this version puts it in the source chain behind ExternalError.
You call this out in the description, so the question is just which consumer you're targeting: does the embedder classify by matching ArrowError variants (then the direct conversion serves them better), or does it go through find_root (then this is fine)? I lean toward keeping the context — the outer error only says "Parquet error", so naming the predicate stage is genuinely useful — but it's worth pinning down the intent.
|
|
||
| let batch = RecordBatch::try_from_iter(vec![( | ||
| "s", | ||
| Arc::new(StringArray::from(vec!["not_an_int"])) as ArrayRef, |
There was a problem hiding this comment.
Small note on the test: the file has one column s and the predicate is on s too, so the projection has zero non-filter columns. Any narrow-projection pushdown heuristic would decline this scan, and assert!(!plan.contains("FilterExec")) would then fail.
Nothing on main does that today, so this is fine as-is — just worth knowing the assertion depends on it.
Give the end-to-end test a column the predicate does not reference, so the projection always has a non-filter column and a narrow-projection pushdown heuristic has no reason to decline the scan, and say in the assertion why a FilterExec would invalidate the test. Also spell out on the `map_err` why the direct conversion is not equivalent: a bare `ArrowError` has no source, so after the decoder re-wraps it as `ParquetError::External` there is no `DataFusionError` left in the chain for `find_root` to recover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The direct conversion doesn't survive the trip: Second point taken as well — the test now has a column the predicate doesn't reference, so a narrow-projection heuristic has no reason to decline the scan, and the assertion explains itself. Update: rather than expanding the comment, I made the construction explicit — relying on // `ExternalError` is the only `ArrowError` variant that keeps a
// source, and therefore the only one that leaves the original error
// recoverable (see `DataFusionError::find_root`)
.map_err(|e| {
ArrowError::ExternalError(Box::new(
e.context("Error evaluating filter predicate"),
))
})Same resulting error, but the variant is named on the line instead of argued for underneath it, so the comment shrank to the one fact that isn't visible from the code. It also restored the |
Relying on `.context()` to route past the `DataFusionError::ArrowError` arm of `From<DataFusionError> for ArrowError` produced the shape we want as a side effect, which took a paragraph of comment to justify. Name the variant instead: `ExternalError` is the only one that keeps a source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
You're right, and the Thanks for the test change too. LGTM. |
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::evaluatemust return anArrowError, soDatafusionArrowPredicate::evaluatebuilt one byDebug-formatting theDataFusionErrorit received:Formatting the error discards its type. Every failure inside a predicate pushed
into the parquet decoder reaches the caller as the same untyped
ArrowError::ComputeErrorcarrying aDebugstring, so a user error such as afailed 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
Debugrather thanDisplay.Reproduction with
datafusion-cli:Before:
After:
What changes are included in this PR?
DatafusionArrowPredicate::evaluatenow converts the error instead offormatting it:
From<DataFusionError> for ArrowErroris the conversion DataFusion alreadydocuments for this boundary. It leaves the original error in the
Error::sourcechain, and the parquet decoder propagates it as
ParquetError::External, whichis also source preserving, so
DataFusionError::find_rootrecovers the originalvariant at the top of the stack. Wrapping the error in a
DataFusionError::Contextfirst keeps the description of where the failurehappened, which the old string also carried.
One consequence worth calling out: because the context has to live somewhere,
the returned
ArrowErrorvariant isExternalErrorrather than the originalArrow variant. Callers that want the type use
find_root(or walkError::source), which is the existing way to recover an error across anArrowErrorboundary in DataFusion and is used the same way indatafusion/common/src/scalar/mod.rsanddatafusion/physical-plan. Droppingthe context would yield a bare
ArrowError::CastErrorhere, at the cost of nolonger 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_errorevaluates a predicate that fails to castand asserts
find_rootreturns theCastError, and that the message stillnames 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_errorruns the same failurethrough 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 passvacuously, 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 fullsqllogictestsuite, all passing. No test expectation elsewhere depended on theold 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.