fix(common): report a cancelled task as an error, not an unreachable arm - #199
Open
claudespice wants to merge 3 commits into
Open
fix(common): report a cancelled task as an error, not an unreachable arm#199claudespice wants to merge 3 commits into
claudespice wants to merge 3 commits into
Conversation
A `JoinError` reports one of exactly two outcomes: the task panicked, or it was cancelled. Seven `JoinSet` drain loops resumed the panic and then called `unreachable!()` for everything else — so the arm written for "this cannot happen" was precisely the arm a cancelled task lands in, converting a cancellation into a panic on a Tokio worker instead of an error the caller can surface. A task is cancelled when it is aborted, or when the runtime it was spawned on shuts down while it is still queued. Neither is unreachable. Add `DataFusionError::from_join_error`, which resumes a panic on the calling thread and returns `DataFusionError::ExecutionJoin` for a cancellation, keeping the `JoinError` as the error's source so callers can still ask `is_cancelled()`. `ExecutionJoin` already existed for this case and is already used a few lines away in two of these files.
`DataFusionError` is now imported in `memory/table.rs`, which makes two pre-existing fully-qualified uses redundant under `-D unused-qualifications`. The panic-path test spawns through the runtime handle rather than `tokio::task::spawn`, which `clippy.toml` disallows in favour of cancel-safe spawning.
2 tasks
There was a problem hiding this comment.
Pull request overview
Fixes cancellation handling in seven JoinSet drain loops, addressing spiceai/spiceai#7030.
Changes:
- Converts cancelled tasks into
ExecutionJoinerrors while preserving panic propagation. - Applies consistent handling across collection and file-writing paths.
- Adds unit and regression tests for cancellation and panic behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
datafusion/common/src/error.rs |
Adds the shared JoinError conversion helper and tests. |
datafusion/physical-plan/src/execution_plan.rs |
Handles cancelled partition tasks and adds a regression test. |
datafusion/catalog/src/memory/table.rs |
Handles cancellation while loading partitions. |
datafusion/datasource-arrow/src/file_format.rs |
Handles cancelled Arrow writer tasks. |
datafusion/datasource-parquet/src/sink.rs |
Handles cancelled Parquet sink tasks. |
datafusion/datasource-parquet/src/writer.rs |
Handles cancelled Parquet plan writers. |
datafusion/datasource-csv/src/source.rs |
Handles cancelled CSV writer tasks. |
datafusion/datasource-json/src/source.rs |
Handles cancelled JSON writer tasks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The variant said a `JoinError` "can't occur for unjoined tasks, such as execution shutdown". That is about a handle nobody awaits, but it reads as ruling out the shutdown-cancelled join this variant now carries. State the contract instead: only a joined task reports a `JoinError`, a cancelled one lands here with the `JoinError` reachable as the source, and a panicking one goes to `from_join_error`'s resume path.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (2)
datafusion/common/src/error.rs:138
- Runtime shutdown cancels every outstanding asynchronous task, including tasks that have already started and yielded; it is not limited to tasks that are still queued. Narrowing this public contract to queued tasks could cause callers to misclassify a cancellation from an in-progress task. Please describe the condition as the runtime shutting down before the task completes.
/// task that was **cancelled** does: it was aborted, or the runtime it was
/// spawned on shut down while it was still queued. The `JoinError` stays
datafusion/common/src/error.rs:439
- This repeats the narrower “still queued” condition, but dropping a Tokio runtime cancels all outstanding async tasks, including ones that were previously polled and are pending. Please document cancellation as shutdown before completion so this helper's public contract covers all
JoinError::is_cancelled()cases.
/// * The task was **cancelled** — it was aborted, or the runtime it was spawned
/// on shut down while it was still queued. That is returned as
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A
tokio::task::JoinErrorreports one of exactly two outcomes: the task panicked, or it wascancelled. Seven
JoinSetdrain loops resumed the panic and then calledunreachable!()foreverything else — so the arm written for "this cannot happen" is precisely the arm a cancelled task
lands in. A cancellation became a panic on a Tokio worker thread instead of an error the caller
could surface.
A task is cancelled when it is aborted, or when the runtime it was spawned on shuts down while the
task is still queued. Running the serving path and the query-execution path on separate runtimes —
a normal deployment shape — makes the second case ordinary rather than hypothetical: the drain loop
is still being polled while the tasks it is waiting on are cancelled underneath it.
Reported as a panic out of
collect_partitioned:Fixes spiceai/spiceai#7030.
Changes
DataFusionError::from_join_errorresumes a panic on the calling thread — unchanged behaviour, soa panicking task still surfaces with its payload and backtrace intact — and returns
DataFusionError::ExecutionJoinfor a cancellation, keeping theJoinErroras the error'ssourceso a caller can still ask
is_cancelled().ExecutionJoin(Box<JoinError>)already existed for exactly this case, and is already used for it afew lines away from two of the sites this changes (
datasource-arrow/src/file_format.rs:339,datasource-parquet/src/sink.rs:391). The inconsistency within those two functions is theclearest evidence this was an oversight rather than a decision.
All seven sites now read
Err(e) => return Err(DataFusionError::from_join_error(e)):physical-plancollect_partitioned(the reported one)catalogMemTable::loaddatasource-arrowArrowFileSink::spawn_writer_tasks_and_joindatasource-parquetParquetSink::spawn_writer_tasks_and_joindatasource-parquetplan_to_parquetdatasource-csvplan_to_csvdatasource-jsonplan_to_jsonTwo properties worth a reviewer's attention:
accumulating something — batches, a row count, writers — and continuing past a cancelled task
would report a partial result as a success.
return Errdiscards the partial accumulation, whichis what the old panic also did.
elsebranches. The duplication had already drifted:two other drains in the tree handle the same case correctly and differently
(
physical-plan/src/stream.rsreturnsexec_err!,common-runtime/src/common.rslogs andreturns the
JoinError). Putting the decision next to the error type is also what makes itunit-testable.
datafusion-common-runtime::join_unwindis the existing precedent for a sharedfunction that resumes panics on the caller's behalf.
The rest of the tree was checked for the same class and is already correct:
datasource/src/write/orchestration.rshandles a cancelled task deliberately ("Don't panic, insteadtry to clean up as many writers as possible"),
common-runtime/src/join_set.rsis a pass-throughwrapper, and the remaining
unwrap()ing drains are all test code. Nounreachable!()in anis_panic()else-branch remains anywhere in the workspace.Test plan
Three tests, each pinned by a neuter that makes it fail:
collect_partitioned_reports_a_cancelled_task_as_an_error— calls the productioncollect_partitionedand reaches the arm the way it is reached in practice: the per-partitiontasks are spawned on one runtime, that runtime is dropped, and a second runtime drives the
collect. Restoring the
unreachable!()fails it.from_join_error_reports_a_cancelled_task_as_execution_join— asserts the variant and that theJoinErrorsurvives as the error's source. Stringifying the cancellation instead fails it.from_join_error_resumes_a_panicking_task_instead_of_returning— asserts the panic is notdowngraded to an error and the payload is carried through. Removing the
resume_unwindfails it.Each neuter fails exactly one of the three, so no test is standing in for another.
cargo fmt --allis not clean on this branch's base, so formatting was applied per-crate to keepunrelated files out of the diff.
Notes
This is also a live bug upstream —
apache/datafusionmainstill has theunreachable!()incollect_partitioned.The runtime side of spiceai/spiceai#7030 needs the
datafusionrev inspiceai/spiceai'sCargo.tomlbumped after this merges; the issue stays open until that lands.