andygrove commented on PR #2196:
URL:
https://github.com/apache/datafusion-ballista/pull/2196#issuecomment-5133343868
Really like the shape of this, and the "a rule only has to splice two
operators" goal is the right one. I dug into one thing and it looks like a real
gap, so I built a repro rather than just asserting it.
## The issue
`update_stage_progress` does the duplication first and attaches the filter
second:
```rust
let remapped = cut_partitions(partitions, reports, &routing.cuts)?; //
straddlers duplicated
self.planner.set_repartition_routing(stage_id, routing)?; // may
silently no-op
```
`set_repartition_routing` returns `Ok(())` when the stage's boundary root is
an `AdaptiveDatafusionExec` rather than an `ExchangeExec`. That is documented
as intentional, but by then `cut_partitions` has already pushed each straddling
file into every overlapping downstream partition. With no routing parked,
`BallistaAdapter` never injects `PerPartitionFilterExec`, so nothing trims them
back. In the final-stage case it goes straight to the user via:
```rust
self.output_locations = partitions.into_iter().flatten().collect();
```
## The reachable shape is the canonical one
I initially assumed you would need something exotic to hit this. You do not.
It is the exact `RuntimeStatsExec -> URRE` splice the PR tells rule authors to
emit, sitting at the root of the plan.
`DistributedExchangeRule::optimize` runs `transform_up` first and only wraps
the result in `AdaptiveDatafusionExec` afterwards. So at transform time the
chain top has no parent for the new branch to visit, and the branch inspects
`execution_plan.children()`. The only node visited is the `RuntimeStatsExec`
itself, whose child is the URRE, which is not a chain top.
## Repro
Three tests against `a92f0617`, all passing.
**Leg 1**, dropped into the `distributed_exchange.rs` test module next to
your existing ones:
```rust
#[test]
fn repro_root_level_chain_top_gets_no_exchange() {
let rule = DistributedExchangeRule::default();
let root = stats_over_urre_over_leaf(); // your existing helper
let result = rule.optimize(root, &config()).unwrap();
let adaptive = result.downcast_ref::<AdaptiveDatafusionExec>().unwrap();
assert!(adaptive.input().is::<RuntimeStatsExec>());
assert_eq!(count_exchanges(result.as_ref()), 0);
}
```
```
AdaptiveDatafusionExec: is_final=false, plan_id=0, stage_id=pending,
stage_resolved=false
RuntimeStatsExec: rows + sketch(routing=v@0 asc)
UnorderedRangeRepartitionExec: routing=v@0 asc → 4 partitions
StatisticsExec: col_count=1, row_count=Absent
exchanges inserted: 0
```
**Leg 2**, composing `cut_partitions` and `set_repartition_routing` in the
order `update_stage_progress` calls them, through a real `AdaptivePlanner`. One
producer file, sketch over `[5, 15, 25]`, single cut at 15:
```
after cut_partitions: 2 downstream partitions, file counts = [1, 1]
set_repartition_routing returned: Ok(())
--- plan after set_repartition_routing ---
AdaptiveDatafusionExec: is_final=true, plan_id=0, stage_id=0,
stage_resolved=false
RuntimeStatsExec: rows + sketch(routing=v@0 asc)
UnorderedRangeRepartitionExec: routing=v@0 asc → 2 partitions
DataSourceExec: partitions=1, partition_sizes=[0]
output_locations file_ids = [7, 7]
```
No `range_repartition_cuts` on the plan, so no `PerPartitionFilterExec`
downstream. File 7 appears twice. Three rows would be read as six.
**Leg 3**, positive control so this is not just me misreading the mechanism.
Same splice, same calls, only a `FilterExec` parent added:
```
AdaptiveDatafusionExec: is_final=false, plan_id=1, stage_id=pending,
stage_resolved=false
FilterExec: v@0 > 0
ExchangeExec: partitioning=None, plan_id=0, stage_id=0,
stage_resolved=false, range_repartition_cuts=1
RuntimeStatsExec: rows + sketch(routing=v@0 asc)
UnorderedRangeRepartitionExec: routing=v@0 asc → 2 partitions
```
Exchange inserted, cuts parked, everything works. Position in the plan is
the only variable between leg 2 and leg 3.
<details>
<summary>Full leg 2 and leg 3 test file</summary>
Add `datafusion-functions-aggregate-common = { workspace = true }` to
`ballista/scheduler` dev-dependencies, drop this in as
`ballista/scheduler/src/state/aqe/test/repro_range_repartition.rs`, and
register `mod repro_range_repartition;` in that directory's `mod.rs`.
```rust
use crate::state::aqe::execution_plan::RangeRepartitionRouting;
use crate::state::aqe::planner::AdaptivePlanner;
use ballista_core::execution_plans::{
RuntimeStatsExec, UnorderedRangeRepartitionExec, cut_partitions,
};
use ballista_core::extension::SessionConfigExt;
use ballista_core::serde::protobuf::{RuntimeStatsPartitionEntry,
RuntimeStatsReport};
use ballista_core::serde::scheduler::{
ExecutorMetadata, ExecutorOperatingSystemSpecification,
ExecutorSpecification,
PartitionId, PartitionLocation, PartitionStats,
};
use datafusion::arrow::compute::SortOptions;
use datafusion::arrow::datatypes::{DataType, Field, Schema};
use datafusion::datasource::memory::MemorySourceConfig;
use datafusion::datasource::source::DataSourceExec;
use datafusion::physical_expr::PhysicalSortExpr;
use datafusion::physical_plan::ExecutionPlan;
use datafusion::physical_plan::expressions::col;
use datafusion::prelude::SessionConfig;
use std::sync::Arc;
fn v_schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![Field::new("v", DataType::Float64, false)]))
}
/// The canonical splice a range-repartition rule is told to emit,
/// here at the root of the plan.
fn stats_over_urre_root() -> Arc<dyn ExecutionPlan> {
let schema = v_schema();
let source: Arc<dyn ExecutionPlan> =
Arc::new(DataSourceExec::new(Arc::new(
MemorySourceConfig::try_new(&[vec![]], schema.clone(),
None).unwrap(),
)));
let sort_expr = PhysicalSortExpr {
expr: col("v", schema.as_ref()).unwrap(),
options: SortOptions { descending: false, nulls_first: false },
};
let urre: Arc<dyn ExecutionPlan> = Arc::new(
UnorderedRangeRepartitionExec::try_new(source,
vec![sort_expr.clone()], 2).unwrap(),
);
Arc::new(RuntimeStatsExec::try_new(urre, Some(vec![sort_expr])).unwrap())
}
/// Same splice with an ordinary parent above it. A FilterExec survives the
/// optimizer pipeline. RoundRobin RepartitionExec and CoalescePartitionsExec
/// do NOT, both get stripped.
fn stats_over_urre_with_parent() -> Arc<dyn ExecutionPlan> {
let pred: Arc<dyn datafusion::physical_expr::PhysicalExpr> =
Arc::new(datafusion::physical_expr::expressions::BinaryExpr::new(
col("v", v_schema().as_ref()).unwrap(),
datafusion::logical_expr::Operator::Gt,
Arc::new(datafusion::physical_expr::expressions::Literal::new(
datafusion::scalar::ScalarValue::Float64(Some(0.0)),
)),
));
Arc::new(
datafusion::physical_plan::filter::FilterExec::try_new(pred,
stats_over_urre_root())
.unwrap(),
)
}
fn location(sub_part_id: usize, producer_task_id: usize, rows: u64) ->
PartitionLocation {
PartitionLocation {
map_partition_id: 0,
partition_id: PartitionId {
job_id: "repro-job".into(),
stage_id: 0,
partition_id: sub_part_id,
},
executor_meta: ExecutorMetadata {
id: format!("exec-{producer_task_id}"),
host: "".to_string(),
port: 0,
grpc_port: 0,
specification: ExecutorSpecification::default().with_vcores(0),
os_info: ExecutorOperatingSystemSpecification::default(),
},
partition_stats: PartitionStats::new(Some(rows), None, None),
file_id: Some(producer_task_id as u64),
is_sort_shuffle: false,
}
}
#[tokio::test]
async fn repro_routing_dropped_after_partitions_already_duplicated()
-> datafusion::error::Result<()> {
let config = SessionConfig::new_with_ballista();
let mut planner =
AdaptivePlanner::try_from_plan(&config, stats_over_urre_root(),
"repro-job".into())?;
let stages = planner.runnable_stages()?;
let stage_id = stages
.as_ref()
.and_then(|s| s.first())
.map(|e| e.plan.stage_id())
.expect("a runnable stage must exist");
// Step 1: the remap, exactly as update_stage_progress calls it.
// Producer task 7, sub-part 0, sketched [5, 25], straddles the cut at
15.
let reports = vec![ballista_core::execution_plans::TaskRuntimeStats {
producer_task_id: 7,
report: RuntimeStatsReport {
order_by: vec![],
partitions: vec![RuntimeStatsPartitionEntry {
partition_id: 0,
row_count: 3,
sketch: Some(ballista_core::execution_plans::sketch_to_proto(
&datafusion_functions_aggregate_common::tdigest::TDigest::new(100)
.merge_unsorted_f64(vec![5.0, 15.0, 25.0]),
)?),
}],
},
}];
let cuts = vec![15.0];
let remapped = cut_partitions(vec![vec![location(0, 7, 3)]], &reports,
&cuts)?;
assert_eq!(remapped[0].len(), 1, "straddler duplicated into partition
0");
assert_eq!(remapped[1].len(), 1, "straddler duplicated into partition
1");
// Step 2: park the routing so a read-side filter can trim it.
let routing = RangeRepartitionRouting {
cuts: cuts.clone(),
routing_expr: col("v", v_schema().as_ref()).unwrap(),
};
let parked = planner.set_repartition_routing(stage_id as usize, routing);
assert!(parked.is_ok(), "silently succeeds even though nothing was
parked");
// Step 3: confirm nothing was actually parked.
let plan_str = format!(
"{}",
datafusion::physical_plan::displayable(planner.current_plan()).indent(true)
);
assert!(
!plan_str.contains("range_repartition_cuts"),
"no ExchangeExec carries the cuts, so no PerPartitionFilterExec
downstream"
);
// What update_stage_progress assigns to output_locations.
let file_ids: Vec<u64> = remapped
.into_iter()
.flatten()
.map(|l| l.file_id.unwrap())
.collect();
assert_eq!(file_ids, vec![7, 7], "same file twice, 3 rows read as 6");
Ok(())
}
#[tokio::test]
async fn control_routing_is_parked_when_chain_top_has_a_parent()
-> datafusion::error::Result<()> {
let config = SessionConfig::new_with_ballista();
let mut planner = AdaptivePlanner::try_from_plan(
&config,
stats_over_urre_with_parent(),
"control-job".into(),
)?;
let stages = planner.runnable_stages()?;
let stage_id = stages
.as_ref()
.and_then(|s| s.first())
.map(|e| e.plan.stage_id())
.expect("a runnable stage must exist");
let routing = RangeRepartitionRouting {
cuts: vec![15.0],
routing_expr: col("v", v_schema().as_ref()).unwrap(),
};
planner.set_repartition_routing(stage_id as usize, routing)?;
let plan_str = format!(
"{}",
datafusion::physical_plan::displayable(planner.current_plan()).indent(true)
);
assert!(
plan_str.contains("range_repartition_cuts=1"),
"control: the cuts must be parked on the boundary ExchangeExec"
);
Ok(())
}
```
</details>
## A side finding that might matter more
I first wrote the control with a `RoundRobinBatch` `RepartitionExec` parent,
then with a `CoalescePartitionsExec` parent. Both were stripped by the
optimizer pipeline, collapsing back to the root-level shape and making my
control fail before I switched to `FilterExec`.
That is worth flagging on its own.
`range_repartition_chain_top_gets_exchange_inserted` builds a `RoundRobinBatch`
parent and calls `rule.optimize()` in isolation, where the parent survives and
the exchange gets inserted. Through the real `AdaptivePlanner` pipeline that
same parent is gone by the time DER runs. So the test passes for a plan shape
the pipeline does not actually produce, and a rule author could write something
that looks protected and lose the protection in production. Might be worth
driving those DER tests through the planner rather than the rule alone.
## What I did not prove
I composed `cut_partitions` and `set_repartition_routing` directly in the
order `update_stage_progress` calls them, rather than driving a full query to a
wrong answer. Since no rule inserts a range repartition yet that is not
constructible on this branch. The link I reasoned about but did not execute is
the graph-side gate, `stages.get(&stage_id)` being `Running` with non-empty
`runtime_stats_reports`. Reading `revive()` it promotes every Resolved stage to
Running including the final one, and the `RuntimeStatsExec` in that stage's
plan is what `collect_reports` walks, so I think it holds. Happy to be told I
have that wrong.
## Suggested fix
Make `set_repartition_routing` error when it cannot find an `ExchangeExec`
to park on, or move it ahead of `cut_partitions` so a failure to attach the
filter aborts before the duplication is committed. Everything else in this code
errors hard on invariant breaks like `missing file_id`, `no usable sketch`, and
`merge_reports returned N groups`, so the silent `Ok(())` is the outlier.
Separately `is_range_repartition_chain_top` probably wants to fire when the
chain top is itself the plan root.
Nothing else in the review turned up anything. No public API breaks, the
`GlobalPartitionMap` and `AdaptivePlanner` renames are both behind private
modules, and the machinery really is inert until a rule lands.
Investigation and repro here were LLM assisted, with the test output and
plan dumps above coming from actual runs against the PR head.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]