fix: classify wrapped FetchFailed as fetch partition error - #2202
Conversation
|
I'm marking this as draft until #2026 is merged so we can enable the disabled chaos harness tests related to shuffle fetching as part of this PR. |
|
I rebased and I'm now enabling the new tests, will push and remove draft when I have everything working. |
04578f8 to
cfd9ad0
Compare
cfd9ad0 to
1a243dd
Compare
When an executor dies after producing shuffle output, the shuffle fetch fails with a typed BallistaError::FetchFailed. The scheduler has a recovery path for this via FailedReason::FetchPartitionError, but the job failed instead of recovering because the typed error was hidden before task failure classification. As the fetch error propagates up the stream it can be carried as ArrowError::ExternalError(FetchFailed), then as DataFusionError::ArrowError(..), and then as BallistaError::DataFusionError before building the task status. By the time it reaches From<BallistaError> for FailedTask, it is no longer a bare FetchFailed, so the classifier falls through to FailedReason::ExecutionError. Fix this in two places: use result.map_err(BallistaError::from) at the executor boundary so the immediate DataFusion/Arrow wrapper can be unwrapped, and add classifier-side recovery for FetchFailed values that still arrive wrapped in DataFusionError, ArrowError::ExternalError, Shared, or Context. Add regression tests for executor-boundary conversion, bare and wrapped FetchFailed classification, and non-fetch classification. Update the existing scheduler fetch-failure recovery test to build its failed task through the real classifier, covering the classification -> recovery -> success path without duplicating the graph scenario.
1a243dd to
6a2e64c
Compare
|
One maybe unreleased question, what do you think if we remove BallistaError from interfaces and return DataFusionError. We can keep simplified BallistaError but it would be packed in DFE:: External? We have a mix of DF and Ballista errors across interfaces and sometimes it gets confusing |
@milenkovicm I agree we should simplify/consolidate as much as possible. However, I think it's a good idea to do this as a follow-up, as the consolidation work will probably branch out into many unrelated areas (this PR is already getting quite loaded). If this sounds good I can take this on after this PR is merged? |
|
I agree, we can take it as a follow up it you believe it makes sense. Had a quick look at this pr, it makes sense, will do another pass later |
Thanks for reviewing. Btw, I removed Draft from this PR yesterday as I thought it was blocking CI. Turns out GH was just borked all day. But let me make sure CI is green and do one last review pass and then ping you for a final review when I feel it's completely done. |
milenkovicm
left a comment
There was a problem hiding this comment.
thanks @villebro great addition, i have just one question/comment regarding errors used im not sure i understand
This stays targeted to the known shuffle fetch path, where FetchFailed travels through ArrowError::ExternalError. A direct DataFusionError::External(Box) fetch path is not known to be produced here and is outside this change.
| .await | ||
| .map_err(|e| DataFusionError::Execution(format!("{e:?}")))?; | ||
| .map_err(|e| { | ||
| DataFusionError::ArrowError( |
There was a problem hiding this comment.
why dont we use DataFusionError::External ?
There was a problem hiding this comment.
Good point. I agree it would be a cleaner/shorter wrapper shape, but switching to it here would require adding an explicit downcast/unwrapping path and corresponding tests, which would probably increase the complexity of this PR. I think it could be an excellent follow-up PR focused specifically on harmonizing these error paths, and one I can take on after this is merged.
| /// Wraps stage plan with addition of references to previous stages | ||
| pub(crate) struct AdaptiveStageInfo { | ||
| pub(crate) plan: Arc<dyn ShuffleWriter>, | ||
| #[allow(dead_code)] // TODO: still not sure if this is needed |
There was a problem hiding this comment.
i like to call myself API visionist I had a hunch it'll be needed in the future 😀
| map_stage_id: usize, | ||
| map_partition_id: usize, | ||
| ) -> FailedTask { | ||
| let err = BallistaError::DataFusionError(Box::new(DataFusionError::ArrowError( |
There was a problem hiding this comment.
same question here, cant we use DFE::External
|
@milenkovicm I left a comment: let me know if you're ok with following up with a targeted follow-up PR for consolidating/simplifying the error paths or if you prefer to have those changes in this PR. |
|
Thanks @villebro we can split them if you prefer it, let's merge this |
Closes #2027.
Rationale for this change
Issue #2027 describes scheduler recovery failing after an executor dies after producing shuffle output. The shuffle fetch path starts with a structured
FetchFailed { executor_id, map_stage_id, map_partition_id, message }, but that error could be flattened into a genericDataFusionError::Execution("...")string while crossing the shuffle-writer handoff. Once flattened, the scheduler saw a non-retryableExecutionErrorinstead of theFetchPartitionErrorsignal it needs to invalidate and re-run the lost map output.The exact location hints in the issue reflect the codebase when the issue was written. The production type loss fixed here is the wrapping/stringification path above.
What changes are included in this PR?
The core fix keeps shuffle-fetch failures structural until task failure classification. The regular and sort-shuffle writers now share the original structural error with every output-partition handoff instead of formatting it into a string, and the executor uses the existing
BallistaErrorconversion at the query-stage boundary.Error behavior before and after:
ExecutionErrorstring.FetchFailederrors becomeFetchPartitionError, including when wrapped throughArrowError::ExternalError,DataFusionError::ArrowError,DataFusionError::Shared, orDataFusionError::Context.ExecutionErrors.DataFusionError, preserving theIoErrorroot so it stays retryable.Cancelledfrom stale task cleanup could be treated as a fatal execution failure.TaskKilled; explicit job cancellation still marks the job terminal before executor tasks are cancelled.Enabling the chaos regression also exposed adjacent recovery gaps needed for that end-to-end scenario:
The chaos harness changes are mostly regression coverage for this fix. Two harness-stability changes are slightly unrelated to the production fix but were needed to avoid CI flakiness while testing it: the chaos executor now enables the existing scheduler-connect retry with a 10s timeout, and the startup canary uses a longer executor timeout.
The
ArrowError::ExternalErrorwrapping is intentional: it matches the shape produced by the shuffle fetch stream boundary, and Ballista's conversion/classification logic now recovers that shape throughSharedandContextwrappers. This PR does not introduce a separateDataFusionError::External(Box<BallistaError>)fetch path.Are there any user-facing changes?
No API or documentation changes.
Behavior change: shuffle fetch failures that were previously reported as non-retryable execution errors are now classified as fetch-partition errors, so the scheduler can resubmit lost map output instead of failing the job. Sort-shuffle IO errors crossing the same handoff keep their retryable IO classification. Executor-side cancellation of stale task attempts during recovery is treated as retryable cleanup, while explicit job cancellation still marks the job terminal before executor tasks are cancelled. AQE also drops stale active shuffle locations for a lost executor before constructing downstream stages.
No breaking changes to public APIs.