Repository navigation
feat: let custom ExecutionPlan nodes restrict themselves to a task's partitions - #2721
Conversation
apache#2633 added a test to this module that names ExecutionPlan, and apache#2613 then moved FailingPlanRewriteExec out of the module and dropped the imports it had needed, ExecutionPlan among them. Each passed CI against its own base. Together they leave the scheduler's test target uncompilable, so cargo check, Clippy and the test jobs have failed on main since apache#2613 landed.
…partitions When a stage is split between tasks, the scheduler restricts each task's plan to the partitions that task runs. It could only restrict the leaves it knew and the two PartitionSliceable operators on a hand-kept list. Any other node holding data per partition was left as it was, so a task that ran partitions 4 and 5 of it executed its partitions 0 and 1, and the stage returned some partitions twice and others not at all, with no error. - PartitionSliceable::restrict_to_partitions takes any number of children, leaves included. It replaces slice_to_partitions, which took one. - SchedulerConfig::partition_sliceable_resolver finds the PartitionSliceable of nodes Ballista does not list. It is asked after the built-in list. - A leaf that nothing can restrict now fails the task unless the task runs all of its partitions in order, instead of running and duplicating output. The scheduler also rejects a restriction that returns the wrong number of partitions.
089383e to
03c13f1
Compare
…olver test (cherry picked from commit 2efa802)
|
I don't understand motivation at all (which is not blocker on its own) is there any way to explain this pr like I'm 4 😂😀 |
|
Sorry. Comet Native needs it. I'll have Claude explain properly tomorrow. |
|
Fair question, sorry the description was dense. Here's the plain version. The problem Ballista sometimes splits one stage across several tasks. Say a stage has 4 partitions and 2 tasks. Task A gets partitions 0 and 1, and task B gets 2 and 3. Before sending a task out, the scheduler rewrites the plan so each task only sees its own partitions. It knows how to do this for the nodes it recognises: shuffle readers, Parquet and in-memory scans, and a couple of operators on a hand-kept list. If a plan contains some other node that holds data per partition, the scheduler leaves it alone. Task B then runs "partitions 0 and 1" of that node instead of 2 and 3. The result is that partitions 0 and 1 come back twice and 2 and 3 never come back. There is no error, just wrong results. Why Comet needs it Comet native executors build plans with their own leaf nodes that hold per-partition data. The scheduler doesn't know about them, so it can't slice them. Today Comet works around that by giving every fragment a fake What the PR does
The first commit is unrelated. It's a one-line import fix for Breaking changes The trait method rename, a third argument on |
| Ok(child) | ||
| } | ||
|
|
||
| /// Take `values[global]` for each global partition index, in task-local |
There was a problem hiding this comment.
Now that the guide points custom nodes at slice_by_global_partition, its out-of-bounds error reaches users. As a DataFusionError::Internal it renders with "This issue was likely caused by a bug in DataFusion's code. Please help us ... file a bug report" (hit this from a resolver-backed node). Could it, and single_child, use plan_err! like checked_restriction / ensure_leaf_runs_whole?
There was a problem hiding this comment.
Good catch. slice_by_global_partition and single_child now return plan_err!, in 29493d9.
| /// whole: one below a collapse, or declared `SinglePartition` by its parent. It | ||
| /// is called for most nodes of every task's plan as tasks are launched, so keep | ||
| /// it cheap, typically a `downcast_ref` to your own types. | ||
| pub type PartitionSliceableResolver = Arc< |
There was a problem hiding this comment.
With this PR a split stage whose DataSourceExec wraps a custom DataSource fails, but I don't see a way for an embedder to make it restrictable: PartitionSliceable: ExecutionPlan rules out implementing it on the DataSource, the orphan rule rules out DataSourceExec, and the resolver returns a reference borrowed from the plan, so it can't hand back a wrapper. Would a resolver that does the restriction itself, e.g. Fn(&Arc<dyn ExecutionPlan>, Vec<Arc<dyn ExecutionPlan>>, &[usize]) -> Option<Result<Arc<dyn ExecutionPlan>>>, work? It would cover this case as well as custom nodes.
There was a problem hiding this comment.
You're right, the orphan rule and the borrowed return type leave no way to restrict a DataSourceExec over a custom source. The resolver now takes the node, its restricted children and the partitions, and returns the restricted node itself, as you suggested. The walker restricts children once and then asks the built-in list and the resolver. There's a test where the resolver builds a replacement for a node without PartitionSliceable. In 29493d9.
…-to-task-partitions Resolve conflicts in the scheduler config, task manager, task builder and state tests. `stages_read_whole` no longer special-cases partition-sliceable nodes, since their children now get the same scopes as any other node. Also address review feedback on apache#2721: - The partition sliceable resolver now restricts the node itself, given the node, its restricted children and the task's partitions, so it can restrict nodes that cannot implement `PartitionSliceable`, such as a `DataSourceExec` over a custom `DataSource`. - `slice_by_global_partition` and `single_child` return plan errors rather than internal errors, so users are not told to file a bug.
…-to-task-partitions # Conflicts: # ballista/scheduler/src/state/mod.rs
| plan_err!( | ||
| "cannot restrict `{described}` to partitions {partitions:?} of its {count}: \ | ||
| the scheduler does not know how to restrict a `{name}`, and left alone it \ | ||
| would number its partitions from zero in every task. A node of your own \ |
There was a problem hiding this comment.
Nit: typo in the user-facing error: "A node of your own be restricted by" is missing "can".
|
|
||
| // A leaf nothing above could restrict is only right if the task runs all | ||
| // of it. | ||
| if children.is_empty() { |
There was a problem hiding this comment.
Only leaves get this check. An unknown node with children that holds per-partition state, for example a third-party operator shaped like RangeFilterExec, still goes through replace_children_if_necessary without any check. Its children get restricted but its own state doesn't, which is the original bug one level up.
I don't think the scheduler can tell that node apart from a stateless FilterExec, so this may not be fixable here. Could the docs and upgrade guide say explicitly that only leaves are guarded? "Fails instead of duplicating output" reads broader than what this does.
There was a problem hiding this comment.
Agreed, the scheduler can't tell such a node from a stateless FilterExec, so only leaves are guarded. The upgrade guide and the extending-components guide now say so explicitly: an interior node of your own that holds data per partition still returns wrong results in a split stage unless a resolver restricts it. In aef82ff. The longer-term fix is a method on ExecutionPlan itself; see #2842.
| /// stage split between tasks that holds such a leaf fails the job. Unlike | ||
| /// the `override_*` fields it adds to Ballista's own list rather than | ||
| /// replacing it. See [`PartitionSliceableResolver`]. | ||
| pub partition_sliceable_resolver: Option<PartitionSliceableResolver>, |
There was a problem hiding this comment.
A question about where this is registered. A custom node can only reach a task if the physical extension codec can serialize it, so whoever registers the codec also knows how to restrict the node. Keeping the two in separate places means they can disagree, and the standalone scheduler already shows that: it builds its own SchedulerConfig, so it can get the codec but not the resolver. For integrations like Iceberg (#2638), a UNION ALL over a custom scan on standalone would then fail with no way to fix it.
Could the standalone builder accept a resolver too, or could the restriction be registered alongside the codec? A follow-up issue would be fine.
There was a problem hiding this comment.
Good point about keeping it next to the codec. Filed #2839 to make the resolver reachable from standalone, preferably by registering it on the SessionConfig alongside the physical codec.
| /// whole: one below a collapse, or declared `SinglePartition` by its parent. It | ||
| /// is called for most nodes of every task's plan as tasks are launched, so keep | ||
| /// the `None` path cheap. | ||
| pub type PartitionSliceableResolver = Arc< |
There was a problem hiding this comment.
Minor: Arc<dyn Fn(&Arc<dyn ExecutionPlan>, Vec<…>, &[usize]) -> Option<Result<…>>> is hard to read in rustdoc, and it doesn't match the trait-based extension points next to it on SchedulerConfig (e.g. adaptive_rules: Arc<dyn AdaptiveRules>). A small trait would also let methods be added later without another breaking change.
Since the doc comment says an upstream select_partitions on ExecutionPlan would replace this, it might be worth filing that in DataFusion and linking it here, so this hook has a planned replacement.
There was a problem hiding this comment.
Done: PartitionSliceableResolver is now a trait (Debug + Send + Sync, one restrict_to_partitions method), like AdaptiveRules, and SchedulerConfig::with_partition_sliceable_resolver takes an Arc<dyn PartitionSliceableResolver>. In aef82ff. For the upstream method, I'd like to settle the design here before proposing it to DataFusion: #2842.
| and 1, and the stage returned some partitions twice and others not at all, with | ||
| no error. | ||
|
|
||
| The scheduler now fails the job instead. Such a leaf is accepted only in a task |
There was a problem hiding this comment.
This is the right trade (an error instead of wrong results), but these failures depend on cluster load. Whether a stage gets split depends on free vcores, and partial retries count as split, so a query with a custom DataSource or something like StreamingTableExec can pass in dev and fail in production. Worth saying that explicitly here.
Related: as the PR description notes, push-staged scheduling hides this message behind "task could not be prepared or was rejected by executor". Now that this is an expected user-facing error, a follow-up issue to surface the reason would help.
There was a problem hiding this comment.
Added a paragraph to the upgrade guide saying that splitting depends on free task slots and on retries, so a query can pass on an idle cluster and fail on a busy one, and suggesting ballista.scheduler.max_partitions_per_task set low for testing. In aef82ff. Filed #2840 for surfacing the reason under push-staged scheduling.
There was a problem hiding this comment.
Would a query fail? I thought it would just get its tasks restricted down to available slots, including to 1, and then succeed, but slower?
There was a problem hiding this comment.
It does fail, but only for plans that contain a leaf the scheduler can't restrict. Your picture is right for everything else. When a stage gets fewer slots, its tasks get fewer partitions each. For a shuffle reader, a Parquet/memory scan, EmptyExec or PlaceholderRowExec, the task's plan is narrowed to those partitions and the query just runs slower, as you describe.
The problem is a leaf the scheduler doesn't know how to narrow, such as a DataSourceExec over a custom DataSource or a third-party scan with no resolver. Cutting the task down to the free slots is exactly when that leaf has to be narrowed. Before this PR it was left as it was, so a task given partitions 2 and 3 actually ran its partitions 0 and 1. The query "succeeded" but returned some partitions twice and others not at all. Now ensure_leaf_runs_whole accepts such a leaf only when the task runs all of its partitions (0..count). Otherwise the job fails with an error naming the node.
So on an idle cluster the stage goes out as one task holding every partition and passes. On a busy cluster, or when part of the stage is retried, it gets split and fails, where before it silently gave wrong results. That's the load dependence the paragraph is warning about. The fix for an affected plan is a resolver (or PartitionSliceable) for the node, and then it behaves like the built-in leaves: restricted, slower, correct.
| /// of its own. | ||
| fn restricts_to_nothing(plan: &Arc<dyn ExecutionPlan>) -> bool { | ||
| restrict_plan_to_partitions(plan.clone(), &[]) | ||
| restrict_plan_to_partitions(plan.clone(), &[], None) |
There was a problem hiding this comment.
Agreed that passing None here is safe, since an unknown node gets its own stage. It does mean a union branch with a resolvable custom node (Comet, Iceberg) loses the inline parallelism. A tracking issue for passing the resolver to the planner would be nice.
| let error = restrict_plan_to_partitions(leaf, &[1], None) | ||
| .unwrap_err() | ||
| .to_string(); | ||
| assert!(error.contains("PayloadExec"), "{error}"); |
There was a problem hiding this comment.
Nit: comparing the full error message instead of using contains would catch wording regressions like the typo in ensure_leaf_runs_whole.
There was a problem hiding this comment.
The test now compares the whole message. In aef82ff.
|
I generally agree with the design, and I'm approving to unblock Iceberg. I couldn't find anything wrong, but I had my AI go through it and it found some nits / doc cleanups + we should open that datafusion Github issue. |
avantgardnerio
left a comment
There was a problem hiding this comment.
I've been wanting to go this direction anyway, thank you for doing this!
CC @phillipleblanc as there's downstream breaking changes.
…-to-task-partitions Keep both 56.0.0 upgrade notes: the PartitionSliceable resolver and the ExecutorEndpoint change to partition locations.
… guarded The resolver is now a trait, like the other extension points on SchedulerConfig, so methods can be added later without another breaking change. Fix the missing "can" in the unrestrictable-leaf error and compare the whole message in its test. The upgrade guide and the extending-components guide now say that only leaves are checked, and that whether a stage is split depends on cluster load and retries. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Thanks for the reviews @NoahKusaba @avantgardnerio @coderfender @milenkovicm |
Which issue does this PR close?
Closes #2679.
Rationale for this change
When the scheduler splits a stage between tasks,
restrict_plan_to_partitionsrewrites the plan so that each task sees only its own partitions. It could restrict only the leavesselect_output_partitionsknows and the two operators on the hand-keptas_partition_sliceablelist. Any other node that holds data per partition was left as it was, so a task that ran partitions 4 and 5 of it executed its partitions 0 and 1, and the stage returned some partitions twice and others not at all, with no error. A downstream crate could implementPartitionSliceablebut had no way to make the scheduler find it, the trait covered only operators with a single child, and a node the crate cannot implement a trait on, such as aDataSourceExecover its ownDataSource, could not be restricted at all.Comet Native Executors (apache/datafusion-comet#7288) need this. They work around it today by giving every fragment a fake
MemorySourceConfigleaf with one row per partition.What changes are included in this PR?
The three things the issue asks for:
PartitionSliceablecovers any node.restrict_to_partitions(children, partitions)replacesslice_to_partitions(child, partitions). It receives all of the node's children, already restricted, and is used for leaves too. The children are restricted the way an interior node's always are, so an input declaredSinglePartitionarrives whole.RangeFilterExecandPrefixMergeExecare ported. The walker also checks that the restricted node haspartitions.len()partitions, because a node that gets that wrong would bring the silent duplication back.SchedulerConfig.partition_sliceable_resolver(builder:with_partition_sliceable_resolver) is called with a plan node, its children already restricted, and the task's partitions, and returns the restricted node, orNonefor a node it does not know. It restricts the node itself rather than returning aPartitionSliceable, so it also covers nodes that cannot implement the trait, such as aDataSourceExec. For a node of your own it typically callsrestrict_to_partitions. It is asked after the built-in list.TaskManagerpasses it to the rewriter. The planner does not use it, so a union branch holding a node Ballista does not know still gets a stage of its own, as before.Nonefromselect_output_partitionsmeans "unrecognised",EmptyExecandPlaceholderRowExecare now returned as recognised, and the constructor errors of the readers and the memory source propagate instead of turning intoNone.Smaller changes:
TaskManager::newnow delegates towith_launcher, so the config is read in one place. There are entries in the 56.0.0 upgrade guide and a "Custom Physical Plan Nodes" section in the extending-components guide.Are there any user-facing changes?
Yes, and some are breaking for anyone who embeds the scheduler or defines
ExecutionPlannodes. All are indocs/source/upgrading/56.0.0.md.PartitionSliceable::slice_to_partitionsis nowrestrict_to_partitionsand takes aVecof children.task_builder::restrict_plan_to_partitionstakes a third argument,Option<&dyn PartitionSliceableResolver>.SchedulerConfighas a new public field,partition_sliceable_resolver, holding anArc<dyn PartitionSliceableResolver>. The new trait'srestrict_to_partitionstakes the node, the restricted children and the partitions, and returnsOption<Result<Arc<dyn ExecutionPlan>>>.DataSourceExecover a source that is neither a file scan nor an in-memory source used to log a warning and run, and is now refused when its stage is split.SchedulerConfig, so it cannot be given a resolver.One limitation: under push-staged scheduling the job's status says only "task could not be prepared or was rejected by executor", and the reason is in the scheduler's log. Pull-staged scheduling puts it in the status. Every task-preparation failure behaves this way today, so it is left alone here.
Testing
New unit tests cover the walker (a leaf found through the resolver, a node without
PartitionSliceablerestricted by the resolver building a replacement, inputs restricted except one the node reads whole, a node below a collapse, the built-in list answering before the resolver, a union branch owned by another task, a leaf nobody can restrict, a wrong partition count, theEmptyExecandPlaceholderRowExecexemption). Twolaunch_taskstests submit a stage that is split between two tasks and holds a custom leaf. Without a resolver the job fails and no task is sent. With one, each task is sent only the payloads of its own partitions, which together are every payload once. Each piece was also broken on purpose, and each break failed the test meant to catch it.cargo fmt,cargo clippy --all-targets --workspace --all-features -- -D warnings,cargo docwith-D warningsand prettier are clean, and theballista-schedulertests pass. Theballista-coretests that bind sockets were not run locally and are left to CI.