Skip to content

feat(scheduler): stage a join's build side before deciding the join - #2434

Merged
andygrove merged 5 commits into
apache:mainfrom
andygrove:aqe-stage-build-side
Sep 12, 2026
Merged

andygrove merged 5 commits into
apache:mainfrom
andygrove:aqe-stage-build-side

Conversation

@andygrove

@andygrove andygrove commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #.

Follows up on the q8 analysis in #2419: #2419 (comment) (items 2 and 3). No issue filed yet, happy to open one if this direction looks right.

Rationale for this change

TPC-H q8 at SF1000 takes 164s, and 131s of that is a single stage: SortShuffleWriterExec Hash([l_partkey], 256) over the raw lineitem scan. All 6B rows of lineitem are shuffled across the network in order to join against 1,332,379 rows of filtered part, which is one p_partkey i64 column, roughly 10.7 MB, about 12x under the 128 MiB broadcast budget. That shuffle should not exist.

It exists because AQE decides the join before it can measure anything. The build side is FilterExec(p_type = 'ECONOMY ANODIZED STEEL') over part, and with no runtime statistics the planner falls back to default_filter_selectivity (20%). The resulting estimate sits over the broadcast budget, so to_actual_join picks PartitionMode::Partitioned and emits JoinSelectionAction::Repartition, which wraps both children in an ExchangeExec. The 131s is committed at that point.

Worse, it is unrecoverable. Stage 0 finishes in 1.4s and reveals the true size, but by then selection_state == Repartitioned, so the only arms to_actual_join can still reach are Hash and SortMerge. There is no path back to CollectLeft.

The same shape shows up in q9 s1 (58s), and the estimate/measurement gap is not specific to q8: any filtered dimension scan feeding a fact-table join hits it.

What changes are included in this PR?

Stage the build side before deciding (JoinSelectionAction::StageBuildSide)

A new action that shuffles only the prospective build side, on the join key, and leaves the probe side untouched. selection_state stays Unknown, so when upstream_resolved() goes true after that one cheap stage, to_actual_join runs again — this time against a resolved ExchangeExec, whose partition_statistics reports Precision::Exact row and byte counts read back from the shuffle's PartitionStats. For q8 that is 1.33M rows and a few MB, which promotes the join to CollectLeft. The probe side is then scanned once inside the join stage against a broadcast build side, and stages 1 and 2 (142s combined) collapse into roughly one 12s stage.

ExchangeExec::to_broadcast shares the stage_id and shuffle_partitions Arcs, so the staged shuffle is read as a broadcast rather than re-run.

requires_build_staging gates this on four conditions, each necessary:

  • the join type must be able to broadcast at all, per collect_left_broadcast_safe on the post-swap join type. partition_mode is CollectLeft only when that same predicate holds, so for a Full join, or a left-sided semi/anti/outer join, no measurement can change the outcome — staging would buy a serialised stage boundary and no decision.
  • the build estimate must be Inexact. An exact size over budget is a fact, not a guess, so measuring it again cannot flip the join and would only serialise two shuffles that could run concurrently.
  • the estimate must be at most stage_build_side_max_estimate_multiple over the byte budget. Past that, no measurement brings it back under. q8's part scan estimates ~10x over and measures three orders of magnitude under.
  • the probe side must be at least stage_build_side_min_probe_ratio times the build side in bytes. Staging serialises the two shuffles, so the deferral costs at most the smaller side's runtime; the ratio is what bounds the cost of being wrong.

It fires at most once per join, guarded on neither child already being an ExchangeExec — once it fires, one child is, so the guard can never pass again. When the measured size then turns out to be too large to broadcast, the join falls through to Repartition, which reuses an exchange already shuffling on the join key (SelectJoinRule::ensure_exchange) instead of nesting a second stage boundary inside the first.

The swap decision cannot rescue a non-broadcastable join type either: supports_swap_join_order is left_bytes > right_bytes, so a build side that is already the left input is the smaller one, and measuring it only shrinks it further.

Configuration

Three keys, all consulted only when staging is enabled, and only for build sides whose size is an estimate rather than a measurement:

  • ballista.optimizer.stage_build_side (Boolean, default true)
  • ballista.optimizer.stage_build_side_min_probe_ratio (UInt64, default 10)
  • ballista.optimizer.stage_build_side_max_estimate_multiple (UInt64, default 32)

stage_build_side_ships_the_documented_defaults pins the shipped values.

Why one-sided shuffling is safe

Worth spelling out, since a half-shuffled join is obviously wrong if it ever executes. It never does, for two independent reasons.

The intermediate plan is never executed. DynamicJoinSelectionExec::execute is an error, and stages come only from ExchangeExec subtrees (find_runnable_exchanges). In the staged state the sole unresolved exchange is the build side's, whose subtree is the filtered dimension scan, so that is the only stage created. The probe side contains no exchange and is never scheduled; the join node sits above the exchange, not below it, so it is in no stage either.

Every executable outcome satisfies the join's distribution requirement. A Partitioned hash join or a SortMergeJoin is reachable only through the JoinInputState::Repartitioned arms of to_actual_join, and that state comes from exactly two places: to_partitioned(), which inserts both exchanges, or inputs_already_partitioned(), which verifies both children satisfy KeyPartitioned exactly. Staging sets neither. The two states it can reach are CollectLeft, which replicates the whole build side to every probe task and needs no co-partitioning, or a fallthrough to Repartition, which shuffles the probe side too. There is no arm that produces a co-partitioned join from a half-shuffled input.

The CollectLeft outcome leaves the build side with the staged exchange's partition count, which DataFusion's CollectLeft mode does not accept on its own. SelectJoinRule converts it with to_broadcast when the probe is partitioned, and EnsureRequirements gathers it with a CoalescePartitionsExec when it is not.

Tests

Unit coverage in dynamic_join for requires_build_staging across every condition, to_actual_join returning StageBuildSide for the q8 shape, Repartition when the config is off, and Repartition rather than a second staging once a child is an exchange. stages_exactly_the_join_types_that_can_broadcast loops all ten join types and compares against collect_left_broadcast_safe as the oracle, so a join type added to DataFusion cannot slip through untested.

state/aqe/test/stage_build_side.rs drives AdaptivePlanner through both passes against declared statistics:

  • stages_the_build_side_then_broadcasts_the_measured_result — pass one shows an exchange on the build side and a bare scan on the probe, the single stage is the build side alone, and after the shuffle reports its measured size pass two resolves to HashJoinExec: mode=CollectLeft over broadcast=true, with the fact table still never shuffled.
  • reuses_the_staged_exchange_when_the_build_side_measures_too_large — the fallthrough, asserting exactly one exchange per side (the build's being the resolved staged one) rather than a nested pair.

cargo test -p ballista-scheduler --lib: 410 passed, 0 failed. Clippy and rustfmt clean.

Review rounds

Two review passes are folded in.

The first added the join-type guard above, which was a real gap: Full was affected unconditionally (Full.swap() == Full), and Left/LeftSemi/LeftAnti/LeftMark whenever the build side was already the left input.

The second was a reuse and simplification pass with no correctness findings, taken essentially in full for a net −232 lines: the StageBuildSide payload dropped (the rule already visits that node) and JoinSide replaced with a build_is_right: bool that makes the unreachable variant unrepresentable; the two (Unknown, Partitioned) arms collapsed into one; with_selection_state removed as a no-op; BuildStagingLimits replaced with plain params to match the sibling predicates; a dead clone dropped from join_key_partitioning; exchange_on renamed to ensure_exchange and made a method. On the test side, six single-assert staging tests folded into two, the two hand-partitioned join-type lists merged onto the collect_left_broadcast_safe oracle, q8_shaped_action* un-forked from run_to_actual_join, and the duplicated session/schema/register fixtures hoisted into test/mod.rs.

Only the four stage_build_side.rs snapshots changed across that pass, with identical plan shapes and plan_ids — which is the evidence it is behaviour-preserving.

Split out of this PR

The broadcast_join_threshold_rows semantics change originally bundled here now lives in #2444. It is not what fixes q8 — at pre-execution q8's build side has an Inexact total_byte_size, so the byte branch short-circuits and the row path is never reached — so the two are easier to review and revert independently.

Benchmarks

Measured on a cluster at TPC-H SF1000, 32 executors on r6i.24xlarge, Parquet on S3, AQE on: the 22-query suite went from 817.07s to 696.09s, with q8 2.36x, q9 2.08x and q21 1.46x.

Those numbers are not being landed in the docs by this PR, and should not be read as final. They were measured before the join-type guard, so they include stages that were staged and discarded on anti and outer joins. They are also one iteration per query: Q1 and Q6 are both join-free, and one improved 4% while the other regressed 7%, so the noise floor is around ±7% — wide enough to explain every small delta in the set, though not the three headline wins, which are an order of magnitude larger.

benchmarking.md is therefore untouched here. I will re-measure with the guard in place and land a docs commit separately after merge, stating the iteration count and the regression band rather than rounding them away.

Known follow-ups, not addressed here

  • The staged side is written as a hash shuffle into target_partitions, so the broadcast read pulls all of them per probe task. Correct, and cheap at 1.33M rows, but an unpartitioned write would be better.
  • The measured total_byte_size is compressed shuffle-file bytes, which understates the in-memory build size. Pre-existing: hash_join_max_build_partition_bytes already reads the same figure.
  • The exchange guard inspects only immediate children, so a build side shaped like FilterExec(ExchangeExec(..)) can still gain a boundary above an existing one. The fire-at-most-once bound is unaffected, since staging always produces a direct exchange child.
  • to_actual_join builds five independent StatisticsContexts per call, each with a fresh cache. Pre-existing; this PR adds no new statistics computation. Threading one through is worth its own PR.
  • CoalescePartitionsRule has no way to clear a CoalescePlan, and both bail paths return without clearing. Not introduced here and not reachable in the staged shape, since finalise_stage_internal replans and re-decides the join before actionable_stages invokes the rule, but the invariant is order-dependent rather than structural.

Are there any user-facing changes?

No API changes.

Three new config keys, listed above. With stage_build_side on, a plan may gain one extra stage boundary and lose a much larger one, so stage counts and the plan shape for affected joins change. Generated config docs regenerated via ./dev/update_config_docs.sh.

AQE's `Repartition` action shuffles both join inputs at once. When the
build side's size is only an estimate, that estimate is the sole reason
the join is not a broadcast, and the probe side is a fact table, this
commits to the single most expensive stage in the query before any
measurement exists. TPC-H q8 at SF1000 shuffles all 6B rows of lineitem
to join against 1.33M rows of filtered `part`, and AQE can never walk it
back: once `Repartition` fires, `selection_state` is `Repartitioned` and
only Hash and SortMerge remain reachable.

Add `JoinSelectionAction::StageBuildSide`, which shuffles the smaller
side alone on the join key and leaves the probe side untouched. The
resolver re-runs once that stage completes, reads the exchange's
measured statistics, and usually promotes the join to `CollectLeft`, so
the probe side is never shuffled. It fires at most once per join,
guarded on neither child already being an exchange, and `Repartition`
now reuses an exchange already on the join key rather than nesting a
second stage boundary inside it.

Also stop applying `broadcast_join_threshold_rows` as a ceiling over the
schema-derived byte estimate. The config documents it as the fallback
for when byte statistics are unavailable, but the code applied both, so
a narrow build side well inside the byte budget was rejected on row
count alone: 1M `i64` keys is 8 MB against a 128 MiB default budget.
The row rule now decides only when no byte estimate can be formed, and
`0` still disables that path.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 8, 2026
@andygrove
andygrove force-pushed the aqe-stage-build-side branch from 42a6ebb to da92ba8 Compare September 8, 2026 21:35
@andygrove

Copy link
Copy Markdown
Member Author

Looks promising:

      "queries": [
        {
          "query_id": "TPCH-8",
          "has_errors": false,
          "has_warnings": false,
          "mean_s": 37.76327075066666,
          "min_s": 36.831046083,
          "max_s": 39.35304816,
          "median_s": 37.105718009,
          "stdev_s": 1.3836203688704116,
          "iterations": 3
        }
      ]

Q8 96.02 -> 40.68, Q9 107.89 -> 51.94 and Q21 95.63 -> 65.69 as the
build side is staged and measured before the join strategy is chosen.
Suite total 817.07 -> 696.09 against an unchanged Spark 3.4 baseline
of 845.21 on the same cluster shape.

Pin the version row to this PR's head and note that the numbers do not
reproduce from main until it lands.
@andygrove
andygrove marked this pull request as ready for review September 8, 2026 22:25
@andygrove
andygrove marked this pull request as draft September 8, 2026 22:29
@andygrove
andygrove marked this pull request as ready for review September 8, 2026 22:31
@milenkovicm milenkovicm mentioned this pull request Sep 9, 2026
1 task
@comphead

Copy link
Copy Markdown
Contributor

Reviewed this against the PR head (1340cc97). Baseline is green for me: 416/416 in cargo test -p ballista-scheduler --lib.

First, the part you asked people to push on — the one-sided shuffle safety argument holds. I traced it rather than taking it on faith: find_runnable_exchanges only pushes ExchangeExec nodes as stages and stops descending at a resolved one, so in the staged state the probe subtree creates no stage, and the join sits above the exchange rather than inside its subtree. Repartitioned is reachable only via to_partitioned() or inputs_already_partitioned(), and staging sets neither. to_broadcast sharing stage_id/shuffle_partitions checks out too.

Two things I'd want changed before merge.

1. Staging fires for join types that can never broadcast

The StageBuildSide guard in to_actual_join checks the config, !null_aware, the exchange guard and should_stage_build_side — but not collect_left_broadcast_safe(build_side_join_type), which is already computed ~80 lines above.

partition_mode is CollectLeft only when under_threshold && collect_left_broadcast_safe(..). That second term is a pure function of the join type, so no measurement can change it. For a non-broadcastable type the second pass can't reach CollectLeft, hits the (now failing) exchange guard, and falls to Repartition — one serialised stage boundary that never had a chance of changing the decision.

I confirmed it with a throwaway test on the q8 shape:

join_type=Full     collect_left_broadcast_safe=false  action=StageBuildSide
join_type=Left     collect_left_broadcast_safe=false  action=StageBuildSide
join_type=LeftSemi collect_left_broadcast_safe=false  action=StageBuildSide
join_type=LeftAnti collect_left_broadcast_safe=false  action=StageBuildSide

Full is affected unconditionally (Full.swap() == Full); Left/LeftSemi/LeftAnti/LeftMark whenever the build side is already the left input.

One line fixes it:

 (JoinInputState::Unknown, PartitionMode::Partitioned)
     if bc.stage_build_side_enabled()
         && !self.null_aware
+        // Measuring the build side can only change the outcome if
+        // CollectLeft is reachable at all, which is a property of the
+        // (post-swap) join type alone.
+        && collect_left_broadcast_safe(build_side_join_type)
         && !self.left.is::<ExchangeExec>()

Applied locally: 415/415 pass, no changes needed to any test in this PR — they all use Inner.

On the +3.1s band: this is a candidate partial explanation, not a diagnosis. q16 (NOT IN, which not_in_subquery_rewrite turns into a plain anti join) and q22 (NOT EXISTS) are both anti joins and both sit in the band. It doesn't explain q5/q7/q12, which are plain inner joins — those look more like the serialisation cost you already acknowledge. Re-running the six with stage_build_side=false per query would separate the two.

2. Nothing tests the multi-pass path

All the new coverage is single-pass: should_stage_build_side in isolation, one to_actual_join call, exchange_on. The mechanism this PR is actually about — stage → resolve with measured stats → re-decide → CollectLeft — isn't exercised, and neither is the EnsureRequirements/CoalescePartitionsExec behaviour you say you verified by hand.

The harness for exactly this already exists in aqe/test/join_selection.rs (AdaptivePlanner + mock_partitions_with_statistics + assert_plan!). Two assert_plan! tests would cover it: the q8 shape resolving to a broadcast CollectLeft, and the fallthrough where the measured size is still too large and exchange_on reuse yields one exchange per side rather than a nested pair.

Worth noting test_left_join_not_collected_left (the #1055 guard) passes today only because its MemTable stats don't match the staging shape — nothing currently pins "a Left join must not stage."

Smaller

  • I'd split the broadcast_join_threshold_rows semantics change out. It's well argued and clearly flagged, but by your own account it isn't what fixes q8, and it replaces a test that asserted the old behaviour deliberately. Separate PRs means either half can be reverted alone if the SF1000 numbers move.
  • The exchange guard only inspects immediate children, so a build side shaped like FilterExec(ExchangeExec(..)) gets a second boundary stacked on the first. Edge case.
  • CoalescePartitionsRule's docs assume both join legs read upstream shuffle output, and attach the same CoalescePlan to every leaf to keep buckets aligned. In the staged state only one leg is an exchange. It's off by default so the risk is low, but does the leaf grouping handle a one-exchange join?

On defaulting to true

Fix #1 first, then yes. As it stands, default-on ships a path that's provably unprofitable for Full and left-sided semi/anti joins, which is a bad trade for queries that get nothing back. After the guard I'd have no objection.

Benchmark methodology is good — real cluster, row counts cross-checked, regressions disclosed rather than smoothed. Two caveats worth stating inline: one iteration per query, and the current totals include wasted stages on any anti/outer join matching the shape, so the post-fix number should come in a little under 696.09s.

I did not check what happens to a resolved exchange's shuffle_partitions when the executor holding that shuffle is lost. Looks pre-existing for all exchanges rather than something this PR introduces, but staging makes one more stage's output load-bearing.

join: Arc<DynamicJoinSelectionExec>,
/// Which child to give an exchange: `true` for `left`, `false` for
/// `right`.
build_is_left: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps having enum BuildSide::Left, Right would be more explanatory and this is generic enum that can be reused it other parts

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed the bool was opaque. I went with DataFusion's existing datafusion::common::JoinSide rather than defining a new BuildSide — it is already the generic Left/Right/None enum for exactly this, and it is already imported elsewhere in this crate (physical_optimizer/join_selection.rs), so it is one less type to keep in sync.

The variant is now build_side: JoinSide, and SelectJoinRule matches on it instead of indexing children with a bool. JoinSide::None is rejected with an internal error, since the resolver only ever produces Left or Right.

///
/// Both sides are compared in bytes. A missing probe-side size declines: with
/// nothing to compare against there is no evidence the round trip pays off.
fn should_stage_build_side(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer something like
requires_build_staging, build_staging_is_neeed, etc

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to requires_build_staging.

/// `p_type = '...'`, and measures three orders of magnitude under it. A raw
/// fact-table scan sits far beyond this multiple and is shuffled without the
/// extra round trip.
const STAGE_BUILD_SIDE_MAX_ESTIMATE_MULTIPLE: usize = 32;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any chances the user wants to override this without rebuilding from source?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — both are config keys now, so they are tunable without a rebuild:

  • ballista.optimizer.stage_build_side_min_probe_ratio (UInt64, default 10)
  • ballista.optimizer.stage_build_side_max_estimate_multiple (UInt64, default 32)

They are read into a new BuildStagingLimits struct alongside broadcast_join_threshold_bytes, which is also where the reasoning for each figure now lives. Both are only consulted when stage_build_side is enabled.

Config docs regenerated via ./dev/update_config_docs.sh, and stage_build_side_ships_the_documented_defaults pins the shipped values, since those are what the SF1000 numbers were measured under.

…ypes

Staging a join's build side can only change the outcome when CollectLeft
is reachable, and that is a property of the post-swap join type alone:
partition_mode is CollectLeft only when `under_threshold &&
collect_left_broadcast_safe(..)`. Without that term in the staging guard
a Full join (Full.swap() == Full), or a left-sided semi/anti/outer join
whose build side is already the left input, would stage, fail the
exchange guard on the second pass, and fall through to Repartition
having bought a serialised stage boundary and no decision.

The swap cannot rescue those cases either: supports_swap_join_order is
left_bytes > right_bytes, so a build side that is already the left input
is the smaller one, and measuring it only shrinks it further.

Also from review:

- Replace `build_is_left: bool` with DataFusion's existing `JoinSide`
  rather than a new enum, and reject `JoinSide::None` explicitly.
- Rename `should_stage_build_side` to `requires_build_staging`.
- Promote the two gating figures to config keys, read through a new
  BuildStagingLimits struct, so they can be tuned without rebuilding:
  stage_build_side_min_probe_ratio and
  stage_build_side_max_estimate_multiple.
- Restore the hash_build_fits doc comment, which the previous commit
  orphaned onto a constant.
- Add state/aqe/test/stage_build_side.rs, covering the two-pass
  mechanism end to end: stage then broadcast against the measured size,
  the too-large fallthrough reusing the staged exchange rather than
  nesting a second one, and a Left join never staging.

The broadcast_join_threshold_rows semantics change moves to apache#2444.
@andygrove

Copy link
Copy Markdown
Member Author

Thanks for tracing the safety argument rather than taking it on faith — that was the part I most wanted someone to actually walk, and your reading of find_runnable_exchanges and the two Repartitioned entry points matches mine.

Both blocking items are addressed, plus the three inline ones. Pushed.

1. Join-type guard — fixed

You're right, and it's a real waste rather than a cosmetic one. Applied exactly as you wrote it.

One thing worth adding, because it was the only way I could see the guard being too strict: the swap decision can't rescue those cases either. supports_swap_join_order is left_bytes > right_bytes, so swap_inputs == false means the build side is already the smaller input — and measuring it only shrinks it further, since the whole premise is that the estimate overshoots. So the swap can never flip false → true and turn a Left into a broadcastable Right after staging. The guard loses nothing.

Both directions are pinned now, across all ten join types: does_not_stage_a_join_type_that_can_never_broadcast and stages_the_join_types_that_can_broadcast.

2. Multi-pass coverage — added

New file ballista/scheduler/src/state/aqe/test/stage_build_side.rs, using the AdaptivePlanner + StatsTable + assert_plan! harness you pointed at. It needed one new fixture helper (estimated_statistics, for an Inexact total_byte_size — sized_statistics is Exact, which never stages) and a parameterised mock_partitions_with_size, with mock_partitions_with_statistics delegating to it.

Three tests:

  • stages_the_build_side_then_broadcasts_the_measured_result — the mechanism end to end. Pass one asserts an ExchangeExec on the build side and a bare scan on the probe; actionable_stages() returns exactly one stage and that stage is the build side alone; then after the shuffle reports its measured size, pass two resolves to HashJoinExec: mode=CollectLeft over broadcast=true, with the fact table still unshuffled.
  • reuses_the_staged_exchange_when_the_build_side_measures_too_large — the fallthrough. Asserts one exchange per side, the build's being the resolved staged one (stage_id=0, stage_resolved=true) and the probe's fresh, rather than a nested pair.
  • does_not_stage_a_left_join — pins the thing you noted nothing currently pins. You were right that test_left_join_not_collected_left passes only incidentally; its MemTable stats don't match the staging shape. These ones do.

418 passed, 0 failed. Clippy and rustfmt clean.

3. Splitting the row-threshold change — done

Now #2444. Agreed on the reasoning: it isn't what fixes q8, and it deliberately replaces a test that asserted the old semantics, so the two should be revertable independently.

Smaller

Exchange guard depth. Acknowledged, and left as is deliberately — I'd rather not widen it without a shape that actually motivates it. The fire-at-most-once bound is unaffected, because staging always produces a direct ExchangeExec child, which the guard does catch. And in the FilterExec(ExchangeExec(..)) case the staged measurement is of the filter's output, not the inner shuffle's, so it isn't redundant work — just an extra boundary. A subtree-wide search would also decline legitimately useful cases, like a build side that is itself a join over an already-resolved broadcast exchange. Noted as a follow-up in the description; happy to tighten if you think the edge case is likelier than I'm reading it.

CoalescePartitionsRule with a one-exchange join. I traced it. Short answer: the leaf grouping handles it fine, and the risk you're pointing at isn't reachable — but it's safe by scheduling order rather than by construction, which I think is worth recording.

The rule is entirely join-agnostic: it collects leaf ExchangeExecs with no notion of which leg they feed, and the only cardinality guard is leaves.is_empty(). A one-leaf group is already routine (any single-input stage reading one shuffle), and start_indices_to_partition_groups covers [0, M) exactly, so coalescing one leg changes task count and never row count.

The window you're describing — build leaf carrying a plan from a one-leaf pass while the probe leaf carries none — can't occur, because the rule only runs after a full replan. finalise_stage_internal calls replan_stages(), which runs SelectJoinRule and re-decides the join; CoalescePartitionsRule runs later, inside actionable_stages(). So the instant the staged exchange resolves, the join is already re-decided, and by the time the rule looks the plan is either broadcast (it bails on any leaf .broadcast) or two leaves with the probe unresolved (it bails on the unresolved leaf). m is also read from properties().partitioning, which set_coalesce never rewrites, so re-running on an already-coalesced leaf re-derives the same M and the heterogeneous-M guard doesn't misfire.

The thing I'd raise separately, which this PR doesn't introduce but does sit close to: nothing ever clears a CoalescePlan. set_coalesce is the only writer, and both bail paths return without clearing — including the degenerate-K path that says so explicitly in its own debug line. If a leaf ever acquires a plan and the group's byte profile later changes such that the rule bails, the stale K persists on one leg while its partner stays at M. Only the replan-before-rule ordering keeps that from being reachable here. Clearing the slot on the two bail paths, or asserting the group agrees on coalesce() before returning, would make it structural. I'd rather do that in its own PR than widen this one — let me know if you'd prefer it bundled.

Executor loss and a resolved exchange's shuffle_partitions. Agreed it looks pre-existing for all exchanges rather than something staging introduces, and agreed that staging makes one more stage's output load-bearing. I haven't checked it either; not planning to in this PR.

Defaults and benchmark numbers

Keeping stage_build_side at true, per your "fix #1 first, then yes".

Both caveats are now stated inline in the description rather than in a footnote: one iteration per query, and the totals were measured before the join-type guard, so they include stages that were staged and thrown away on anti and outer joins. The post-fix total should land slightly under 696.09s. I haven't re-measured on a cluster yet.

Your partial explanation for the +3.1s band holds up and I've written it into the description: q16 (NOT IN → anti join via not_in_subquery_rewrite) and q22 (NOT EXISTS) are both anti joins, and the guard stops both from staging at all now. It doesn't cover q5/q7/q12, which are plain inner joins — those look like the serialisation cost. Re-running the six with stage_build_side=false per query to separate the two is the next measurement I plan to take.

@comphead

Copy link
Copy Markdown
Contributor

some s3 tests are failing

@comphead

comphead commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

@comphead comphead left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran a reuse / simplification / efficiency / altitude pass over the diff, following up on my earlier review. No correctness findings. The one-sided shuffle argument still holds and I think the mechanism is at the right depth. Everything below is quality only, and none of it is blocking.

Rough total if all of it is taken: ~340 lines, out of ~917 lines of new Rust (~90 production, ~250 test).

Best value-to-effort, all mechanical and behaviour-preserving:

  • fold the six requires_build_staging tests (~80)
  • un-fork q8_shaped_action* from run_to_actual_join (~42)
  • merge the two join-type loops onto the existing collect_left_broadcast_safe oracle (~35)
  • BuildStagingLimits to plain params, matching the three sibling predicates in the same file (~31)
  • mock_partitions_with_statistics_no_data to mock_partitions_with_size(0, 0) (~21)

Two things I checked and am not asking for:

  • Dropping stage_build_side_max_estimate_multiple. A filtered fact-table scan reports Inexact and can sit orders of magnitude over budget, so the Inexact gate does not already cover it. The multiple does real work.
  • Turning the two knobs into constants. House style is config keys (all eight pre-existing numeric AQE knobs are), and I was the one who asked for them to be tunable.

Not introduced here, noted for later: to_actual_join builds five independent StatisticsContexts per call (L288, L306, L324, L326, plus one inside supports_collect_by_thresholds), each with a fresh empty StatsCache, so each child subtree is walked about 2.5 times with no memo sharing. To the PR's credit it adds zero new statistics computation and reuses what is already there. It just adds a fourth consumer, which makes this a natural moment to thread one context through.

Tiny, and invisible to CI: dynamic_join.rs:901's use datafusion::common::{ColumnStatistics, Statistics, stats::Precision}; is now fully shadowed by use super::*, since the parent import gained Statistics and Precision. rustc does not warn on an explicit use over a glob, so clippy -D warnings will not catch it.

Comment on lines +218 to +224
StageBuildSide {
join: Arc<DynamicJoinSelectionExec>,
/// Which child to give an exchange. Only ever [`JoinSide::Left`] or
/// [`JoinSide::Right`] — the resolver picks the side the join would
/// build from, which is never absent.
build_side: JoinSide,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things collapse here.

join is a field-identical copy of the node SelectJoinRule is already visiting (see the with_selection_state call below), so the payload can go and the arm can rebuild from the visited node.

JoinSide is a three-variant type for a two-state fact, which is what forces the unreachable JoinSide::None arm in join_selection.rs. The BuildSide { Left, Right } I originally suggested would have made that unrepresentable. A plain build_is_right: bool does too, and the producer below already computes exactly that as swap_inputs.

~13 lines.

Comment on lines +398 to +417
(JoinInputState::Unknown, PartitionMode::Partitioned)
if bc.stage_build_side_enabled()
&& !self.null_aware
// Measuring the build side can only change the decision if
// `CollectLeft` is reachable at all, and that is a property
// of the (post-swap) join type alone — no measurement makes
// a `Full`, or a left-sided semi/anti/outer, join
// broadcastable. `partition_mode` above is `CollectLeft`
// only when this same predicate holds, so without it here
// staging would buy a serialised stage boundary and no
// decision.
&& collect_left_broadcast_safe(build_side_join_type)
// Firing at most once per join is what keeps this from
// ping-ponging: once it fires, one child is an
// `ExchangeExec` and this guard never passes again.
&& !self.left.is::<ExchangeExec>()
&& !self.right.is::<ExchangeExec>()
&& requires_build_staging(
build_stats,
if swap_inputs { &stats_left } else { &stats_right },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things in one guard.

  • !self.null_aware (L400) is already implied. partition_mode is if self.null_aware || (..) { CollectLeft } else { Partitioned }, so matching Partitioned guarantees it.
  • The 8-line comment at L401-408 sits inside a match pattern. The same rationale is already on requires_build_staging, on the join-type test, and in stage_build_side.rs. Keep one copy.
  • if swap_inputs { &stats_left } else { &stats_right } (L417) is the third swap_inputs ternary in this function. L292 and L328 already do it for build_side and build_stats.

Structural: this arm and the fallthrough at L433 have the identical pattern, separated only by the guard, so a reader scans 25 lines past the pattern before learning what it matches. Hoisting probe_stats next to build_stats and folding the conditions into one should_stage_build_side(..) predicate collapses both into one arm, and retires the "the caller is responsible for the fourth condition" paragraph at L720.

Comment on lines +424 to +426
Ok(JoinSelectionAction::StageBuildSide {
join: Arc::new(self.with_selection_state(JoinInputState::Unknown)),
build_side: if swap_inputs {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with_selection_state(Unknown) is a no-op here. JoinInputState has two variants, and this arm is reachable only when the local selection_state (L366-372) is Unknown, which requires self.selection_state == Unknown already. So this copies all 11 fields and changes nothing.

Combined with dropping the payload, with_selection_state can go entirely and to_partitioned can return to its inline body. Caller counts today: to_partitioned 1, with_selection_state 2, one of which is to_partitioned itself.

Comment on lines +660 to +696
/// The configured limits that decide whether staging a join's build side on its
/// own is worth an extra round trip. See [`requires_build_staging`].
#[derive(Debug, Clone, Copy)]
struct BuildStagingLimits {
/// `ballista.optimizer.broadcast_join_threshold_bytes`: the budget a build
/// side must come in under to be broadcast. `0` disables broadcast
/// promotion, and with it any reason to measure the build side again.
broadcast_threshold_bytes: usize,
/// `ballista.optimizer.stage_build_side_min_probe_ratio`: how many times
/// larger the probe side must be before staging pays off.
///
/// Staging serialises the two shuffles that `Repartition` would otherwise
/// run concurrently, so the deferral costs at most the *smaller* side's
/// runtime. Requiring an order of magnitude keeps that cost well under the
/// probe-side shuffle it stands to avoid entirely.
min_probe_ratio: usize,
/// `ballista.optimizer.stage_build_side_max_estimate_multiple`: how far over
/// the broadcast budget an *estimated* build side may sit and still be worth
/// measuring.
///
/// TPC-H q8's filtered `part` scan estimates roughly 10x over budget,
/// because the planner falls back to `default_filter_selectivity` for
/// `p_type = '...'`, and measures three orders of magnitude under it. A raw
/// fact-table scan sits far beyond this multiple and is shuffled without the
/// extra round trip.
max_estimate_multiple: usize,
}

impl BuildStagingLimits {
fn new(bc: &BallistaConfig, broadcast_threshold_bytes: usize) -> Self {
Self {
broadcast_threshold_bytes,
min_probe_ratio: bc.stage_build_side_min_probe_ratio(),
max_estimate_multiple: bc.stage_build_side_max_estimate_multiple(),
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

37 lines to bundle three usizes for one call site, and it diverges from the convention in this same file. supports_collect_by_thresholds(plan, threshold_byte_size, threshold_num_rows) (L509), hash_build_fits(..) (L766) and broadcast_build_side_pays_off(..) (join_selection.rs:176) all take plain params. I grepped ballista/scheduler and ballista/core: this is the only limits bundle in the tree.

It is also internally inconsistent. Two limits come from the &BallistaConfig it receives, the third is passed by hand even though it is just bc.broadcast_join_threshold_bytes() from L274.

Plain params drop ~31 lines and take the stage_limits test helper with them. Worth keeping: the q8 part-scan anecdote at L680-684, relocated as one sentence onto requires_build_staging. That one is measured evidence rather than prose.

Comment on lines +708 to +718
/// Each of the three conditions is necessary:
///
/// * the build estimate must be **inexact**. An exact size over budget is a
/// fact rather than a guess, so measuring it again cannot change the outcome
/// and would only serialise two shuffles that could run concurrently.
/// * the estimate must be **plausibly wrong enough to flip**. Past
/// [`BuildStagingLimits::max_estimate_multiple`] the side is large on any
/// reading, and no measurement brings it under budget.
/// * the probe side must be **much larger**, per
/// [`BuildStagingLimits::min_probe_ratio`], which is what bounds the cost of
/// being wrong.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three bullets map 1:1 onto the three early returns immediately below.

Worth keeping: L698-706 (what Repartition actually costs, which is the real why) and L724-725 (why a missing probe size declines, not derivable from else { return false }).

Comment on lines +62 to +85
fn join_schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("val", DataType::Int32, false),
]))
}

/// A context carrying Ballista's shipped configuration, so these tests run
/// against the thresholds a deployment uses — including
/// `ballista.optimizer.stage_build_side`, which defaults to on.
fn ballista_ctx() -> SessionContext {
let config = SessionConfig::new_with_ballista()
.with_target_partitions(4)
.with_round_robin_repartition(false);
let state = SessionStateBuilder::new_with_default_features()
.with_config(config)
.build();
SessionContext::new_with_state(state)
}

fn register(ctx: &SessionContext, name: &str, stats: Statistics) {
ctx.register_table(name, Arc::new(StatsTable::new(join_schema(), stats, 4)))
.unwrap();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I diffed these against broadcast_thresholds.rs:

  • ballista_ctx() is byte-identical to broadcast_thresholds.rs:80-88
  • join_schema() is byte-identical to its narrow_schema() (L71-76)
  • register() is its L90-93 with the schema param dropped, so strictly less capable
  • const MB is a third copy (L47 there, and dynamic_join.rs:911)

test/mod.rs is already the home for cross-file AQE fixtures (mock_context, mock_schema, mock_partitions_*). Hoisting these there and importing in both files nets ~25 lines, and more importantly stops "the shipped session config" from meaning two different things.

Comment on lines +200 to +229

/// A `Left` join can never be lowered to `CollectLeft` — the build side would
/// emit its unmatched rows once per probe task (#1055) — so measuring it cannot
/// change the decision, and staging would buy a serialised stage boundary for
/// nothing. Both sides must be shuffled on the first pass.
///
/// Worth pinning separately: `test_left_join_not_collected_left` covers the
/// broadcast half of this, but passes today only because its statistics do not
/// match the staging shape. These ones do.
#[tokio::test]
async fn does_not_stage_a_left_join() {
let ctx = q8_shaped_ctx();
let planner = planner_for(
&ctx,
"SELECT dim.val FROM dim LEFT JOIN fact ON dim.id = fact.id",
)
.await;

assert_plan!(planner.current_plan(), @ "
AdaptiveDatafusionExec: is_final=false, plan_id=3, stage_id=pending, stage_resolved=false
ProjectionExec: expr=[val@1 as val]
DynamicJoinSelectionExec: plan_id=0, join_type=Left, on=[(id@0, id@0)] repartitioned=true
ExchangeExec: partitioning=Hash([id@0], 4), plan_id=1, stage_id=pending, stage_resolved=false
CooperativeExec
StatsExec: partitions=4, rows=Inexact(1000000), bytes=Inexact(209715200)
ExchangeExec: partitioning=Hash([id@0], 4), plan_id=2, stage_id=pending, stage_resolved=false
CooperativeExec
StatsExec: partitions=4, rows=Exact(1000000000), bytes=Exact(42949672960)
");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The justification in the doc does not quite hold up. test_left_join_not_collected_left (test/join_selection.rs:190-198) already snapshots the identical two-exchange Left shape, and JoinType::Left is already a case in does_not_stage_a_join_type_that_can_never_broadcast. The genuinely unique content is "with q8-shaped statistics too", which is one row of the merged loop.

Judgment call rather than a clear cut, since broadcast_thresholds.rs does pair integration positives and negatives. But it is a ~30-line plan snapshot that will need re-blessing on every unrelated plan_id shift.

Comment on lines +227 to +246
/// Statistics for a table whose size is an *estimate* rather than a
/// measurement, as a filtered scan reports it before the filter has run.
///
/// This is the shape that makes staging a join's build side worthwhile: the
/// planner falls back to `default_filter_selectivity` and produces a figure
/// that is over the broadcast budget but carries no evidence behind it.
pub(crate) fn estimated_statistics(
schema: &Schema,
num_rows: usize,
total_byte_size: usize,
) -> Statistics {
use datafusion::common::{ColumnStatistics, stats::Precision};

Statistics {
num_rows: Precision::Inexact(num_rows),
total_byte_size: Precision::Inexact(total_byte_size),
column_statistics: vec![ColumnStatistics::new_unknown(); schema.fields().len()],
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is sized_statistics with Exact swapped for Inexact, and DataFusion ships the conversion:

pub(crate) fn estimated_statistics(schema: &Schema, num_rows: usize, total_byte_size: usize) -> Statistics {
    sized_statistics(schema, num_rows, total_byte_size).to_inexact()
}

(Statistics::to_inexact, datafusion-common-55.0.0/src/stats.rs:485.)

Counterpoint: three sibling constructors is this file's existing shape, so low value either way.

Comment on lines +29 to +41
| Ballista | [`da92ba8d`](https://github.com/apache/datafusion-ballista/pull/2434/commits/da92ba8d1bce6e2fd76d01aa86a8c971fa34ac2f) (2026-09-08), Cargo pkg `54.0.0`, DataFusion `55.0.0` |
| Spark | 3.4 (vanilla, no acceleration plugin) |

```{note}
`da92ba8d` is the scheduler commit of
[#2434](https://github.com/apache/datafusion-ballista/pull/2434) — `main`
as of 2026-09-08 plus that PR's change, which stages a join's build side
and reads its measured statistics before `DynamicJoinSelectionExec`
commits to a strategy. It accounts for almost all of the improvement over
the previous (`67b3a19b`) result set — Q8 96.02 → 40.68, Q9 107.89 →
51.94, Q21 95.63 → 65.69 — so a run from `main` before #2434 lands will
not reproduce these numbers. Re-pin this row to the merge commit once it
does.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked-in docs pinned to an unmerged PR commit, carrying an unowned TODO ("Re-pin this row to the merge commit once it does"). This is also the only {note} admonition in the whole docs/source tree, so it is not an established pattern here.

Leaving the row on 67b3a19b and landing a separate docs commit after merge avoids the self-invalidating state.

Comment on lines +150 to +172
| 1 | 16.81 | 67.58 |
| 2 | 30.24 | 29.80 |
| 3 | 31.01 | 25.13 |
| 4 | 20.50 | 21.19 |
| 5 | 63.62 | 54.12 |
| 6 | 15.28 | 1.23 |
| 7 | 52.46 | 19.57 |
| 8 | 40.68 | 48.60 |
| 9 | 51.94 | 69.38 |
| 10 | 56.01 | 35.92 |
| 11 | 18.24 | 30.88 |
| 12 | 19.60 | 10.78 |
| 13 | 14.19 | 20.45 |
| 14 | 15.70 | 7.00 |
| 15 | 19.30 | 23.75 |
| 16 | 17.40 | 23.41 |
| 17 | 31.15 | 82.30 |
| 18 | 51.41 | 129.40 |
| 19 | 18.99 | 11.26 |
| 20 | 33.49 | 19.22 |
| 21 | 65.69 | 101.53 |
| 22 | 12.40 | 12.71 |
| **Total** | **696.09** | **845.21** |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q6 is join-free (single-table scan, filter, aggregate on lineitem) and regressed 7% (14.25 to 15.28), while the equally join-free Q1 improved 4%. At one iteration per query that is a noise floor of roughly plus or minus 7% being published as measured signal, right next to a note saying this PR "accounts for almost all of the improvement".

The three headline wins are an order of magnitude above that band and survive it fine. The 13 small regressions do not, and the doc says nothing about them while the PR description does.

@milenkovicm milenkovicm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @andygrove & @comphead ,

this makes sense to me, when initial DJ implementation was created i had a question if scheduling both sides at the same time makes sense. i believe this is a good balance between cluster utilisation and plan adaptility.

maybe as a follow up we should consider work in #1916 and cardinality estimation, with hope it could bring better join decision

}

impl BuildStagingLimits {
fn new(bc: &BallistaConfig, broadcast_threshold_bytes: usize) -> Self {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: consider adding max_probe_ratio & max_estimate_multiple, instead of BC, "law of demeter" principle. at the moment we have mix and match of paratemters as broadcast_threshold_bytes comes from BC as well

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken. BuildStagingLimits is gone: requires_build_staging now takes min_probe_ratio and max_estimate_multiple as plain usize params rather than reaching through &BallistaConfig, and broadcast_threshold_bytes is passed the same way — so the mix of "two read off BC, one passed by hand" is gone with it.

This also lines up with comphead's separate point that the three sibling predicates in this same file (supports_collect_by_thresholds, hash_build_fits, broadcast_build_side_pays_off) all take plain params, so the bundle was the odd one out.

/// promotion, and with it any reason to measure the build side again.
broadcast_threshold_bytes: usize,
/// `ballista.optimizer.stage_build_side_min_probe_ratio`: how many times
/// larger the probe side must be before staging pays off.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a difference if statictics are exact or estimated ?

///
/// Each of the three conditions is necessary:
///
/// * the build estimate must be **inexact**. An exact size over budget is a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this answers my question from, reasoning make sense to me.

Comment thread ballista/core/src/config.rs Outdated
ConfigEntry::new(BALLISTA_STAGE_BUILD_SIDE.to_string(),
"Stages a join's prospective build side as its own shuffle before \
choosing the join strategy, when that side's size is only an \
estimate and the probe side is far larger. Lets AQE measure the \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps we should mention that we're working with estimated statistics only

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken. All three stage_build_side* entries now say it explicitly — staging applies only where the build side's size is an estimate rather than a measurement, because an exact size over the broadcast budget is a fact that no further measurement can change.

The code says the same thing in one place now too: requires_build_staging returns early unless total_byte_size is Precision::Inexact.

Config docs regenerated via ./dev/update_config_docs.sh.

Quality pass following comphead's reuse and simplification review, which
found no correctness issues. Behaviour preserving: the only snapshots
that changed are the four in stage_build_side.rs, and their plan shapes
and plan_ids are identical to before.

Production code:

- Drop the `join` payload from `JoinSelectionAction::StageBuildSide`. It
  was a field-identical copy of the node `SelectJoinRule` is already
  visiting, so the arm now rebuilds from that node.
- Replace `build_side: JoinSide` with `build_is_right: bool`. `JoinSide`
  is three variants for a two-state fact, which forced an unreachable
  `JoinSide::None` arm; the producer already computes the bool as
  `swap_inputs`.
- Collapse the two `(Unknown, Partitioned)` arms into one. `!null_aware`
  was implied by matching `Partitioned`, and the join-type condition
  moves inside `requires_build_staging`.
- Drop `with_selection_state`, a no-op at its only new call site, and
  restore `to_partitioned`'s inline body.
- Replace `BuildStagingLimits` with plain params, matching the three
  sibling predicates in the same file.
- Hoist `probe_stats` beside `build_stats` instead of a third swap
  ternary.
- `join_key_partitioning` returns a tuple by value over the owned Vec,
  dropping a dead clone. `exchange_on` becomes
  `SelectJoinRule::ensure_exchange`, keeping its laziness through the
  receiver and documenting why it uses structural equality rather than
  `Partitioning::satisfaction`.

Tests:

- Fold six single-assert staging tests into two behind a `stages(..)`
  predicate helper.
- Merge the two hand-partitioned join-type lists into one loop over all
  ten types, with `collect_left_broadcast_safe` as the oracle, so a join
  type added to DataFusion cannot slip through untested.
- Un-fork `q8_shaped_action*` from `run_to_actual_join` by giving the
  latter a `stage_build_side` param.
- Parameterise the fourth `StatisticsExec` builder; the others wrap it.
- Hoist `ballista_ctx`, `narrow_schema`, `register_stats_table` and `MB`
  into `test/mod.rs` so "the shipped session config" means one thing.
- `mock_partitions_with_statistics_no_data` delegates to
  `mock_partitions_with_size`; `estimated_statistics` uses DataFusion's
  `Statistics::to_inexact`.
- Drop `does_not_stage_a_left_join` and
  `exchange_on_wraps_a_non_exchange_child`, both covered elsewhere.

Config entries now state that staging applies only to estimated
statistics.

Reverts the benchmarking.md update: it pinned a row to an unmerged PR
commit behind an unowned TODO, and those numbers predate the join-type
guard. They will be re-measured and landed separately.
@andygrove

Copy link
Copy Markdown
Member Author

Thanks both. @milenkovicm for the approval, and @comphead for the second pass — I took essentially all of it. Net −232 lines (386 insertions, 618 deletions) across the branch, and 410 passed / 0 failed, clippy and rustfmt clean.

The load-bearing check: the only snapshots insta accepted were the four in stage_build_side.rs, and their plan shapes and plan_ids are byte-identical to before the refactor. Nothing in test/join_selection.rs or the plan-stability files moved, which is the evidence that this round is behaviour-preserving.

I verified your three structural claims rather than taking them on faith, and all three hold: create_partitioning(self, ..) does consume (so the clone in join_key_partitioning was genuinely dead), partition_mode is Partitioned only when !null_aware, and the local selection_state can only be Unknown when self.selection_state already is — so with_selection_state(Unknown) really was copying eleven fields to change nothing.

Taken

Production. Dropped the join payload (the arm rebuilds from the visited node); build_side: JoinSide → build_is_right: bool, which deletes the unreachable JoinSide::None arm, its error string and the JoinSide import from both files; collapsed the two (Unknown, Partitioned) arms into one with the join-type condition moved inside requires_build_staging (which retires the "caller is responsible for the fourth condition" paragraph); removed with_selection_state and restored to_partitioned's inline body; BuildStagingLimits → plain params; probe_stats hoisted beside build_stats; join_key_partitioning returns a tuple by value over the owned Vec; exchange_on → SelectJoinRule::ensure_exchange.

On the naming: agreed exchange_on read as "on the join keys". The receiver keeps the fetch_add laziness without the generic, as you suggested. I also took the doc line about inputs_already_partitioned — the structural comparison is deliberate, since the only exchange worth reusing is one this rule just placed, and that is now written down so nobody unifies them later.

Tests. Six single-assert staging tests folded into two behind a stages(build, probe, threshold) predicate; the two hand-partitioned join-type lists merged into one loop over all ten with collect_left_broadcast_safe as the oracle (your point that a new DataFusion join type fails neither current list is exactly right, and the merged form does catch it); q8_shaped_action* un-forked from run_to_actual_join, which gained a stage_build_side param; the fourth StatisticsExec builder parameterised with the other three as wrappers; mock_partitions_with_statistics_no_data delegating to mock_partitions_with_size; estimated_statistics using Statistics::to_inexact.

You were right that the fixtures in stage_build_side.rs were a straight copy — ballista_ctx, narrow_schema, register_stats_table and MB now live in test/mod.rs and both files import them, so "the shipped session config" means one thing.

Dropped does_not_stage_a_left_join and exchange_on_wraps_a_non_exchange_child. On the first, your read was better than mine: Left is already a row of the merged loop with the same q8-shaped statistics, so the only unique content was a plan snapshot that needs re-blessing on every unrelated plan_id shift.

Also fixed the shadowed use datafusion::common::{ColumnStatistics, Statistics, stats::Precision}; — good catch, since as you say -D warnings never would have.

Not taken

The exchange-guard depth, deliberately. The fire-at-most-once bound is unaffected, because staging always produces a direct ExchangeExec child, which the guard does catch. In the FilterExec(ExchangeExec(..)) shape the staged measurement is of the filter's output rather than the inner shuffle's, so it is an extra boundary but not redundant work, and a subtree-wide search would decline legitimately useful cases — a build side that is itself a join over an already-resolved broadcast exchange, for one. Noted as a follow-up; happy to tighten if you think the shape is likelier than I am reading it.

Threading one StatisticsContext through to_actual_join. Agreed it is the natural moment, and agreed this PR adds no new statistics computation. But it is a change to a path five callers share, with no test that would catch a memo-sharing mistake, so I would rather it not ride along behind a behaviour-preserving cleanup. Separate PR.

@milenkovicm

Law of Demeter: taken, and it lines up with comphead's item — requires_build_staging now takes min_probe_ratio and max_estimate_multiple as plain params rather than reaching through &BallistaConfig, and broadcast_threshold_bytes is passed the same way, so the mix-and-match is gone.

Config wording: also taken. All three stage_build_side* entries now say explicitly that staging applies only where the build side's size is an estimate rather than a measurement — an exact size over the budget is a fact no further measurement can change. Docs regenerated.

And yes on #1916 and cardinality estimation as the follow-up. Better cardinalities would shrink the window this heuristic exists to cover, which is the right direction.

Benchmarks and docs

I reverted the benchmarking.md change entirely, per your point. It pinned a row to an unmerged PR commit behind an unowned TODO, and it was the only {note} admonition in docs/source. The deeper problem is the one you found in Q1 vs Q6: both are join-free, one improved 4% and the other regressed 7%, so a ±7% noise floor was being published as measured signal next to a claim that this PR accounts for almost all of the improvement.

Those numbers also predate the join-type guard, so they are stale regardless. I will re-measure on the cluster and land the docs separately after merge, with the iteration count and the regression band stated rather than rounded away.

CI

The S3 failures look like the MinIO issue you linked (#2446) rather than anything here.

@comphead comphead left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @andygrove

@andygrove
andygrove merged commit 2c1357f into apache:main Sep 12, 2026
26 checks passed
@andygrove

Copy link
Copy Markdown
Member Author

Merged. Thanks @milenkovicm and @comphead

@andygrove
andygrove deleted the aqe-stage-build-side branch September 12, 2026 21:08
NoahKusaba added a commit to NoahKusaba/datafusion-ballista that referenced this pull request Sep 13, 2026
Merging main brought in the OpenAPI endpoint (apache#2397) and build-side
staging for AQE joins (apache#2434). Neither was reflected in the docs this
branch had already made complete, and auditing them turned up two older
inaccuracies in the same sections.

From the merged changes:

- Document the new `utoipa` feature on ballista-core in the root and
  crate READMEs, and note that the scheduler's `rest-api` feature now
  also serves the OpenAPI spec.
- Add build-side staging to the AQE optimization lists in the tuning and
  architecture guides, and to the 55.0.0 upgrade guide, since it is a new
  default that changes query plans.

Pre-existing inaccuracies:

- The tuning guide's Join Strategy section only described the static
  planner. With AQE on by default, `prefer_hash_join` is not consulted;
  the join is chosen at runtime from the broadcast threshold and
  `hash_join_max_build_partition_bytes`. The section now covers both
  paths, and notes that the static planner only promotes hash joins to
  broadcast.
- Join reordering compares byte sizes first and falls back to row counts,
  not row counts alone.
- The REST API was described as an optional feature to enable, but
  `rest-api` is on by default.
milenkovicm pushed a commit that referenced this pull request Sep 28, 2026
* docs: correct stale defaults, flags, and feature docs

AQE was documented as experimental and off by default; it is on by default.
The scheduler policy default is push-staged and the event-loop buffer is 1000.
ballista-cli has no spark-compat feature. The REST table was missing endpoints
and both health probes, the Prometheus list named a metric that is not
exported, and every second-executor example moved only --bind-port. CLI usage
is regenerated from the built binary. Also brings the docs up to date with
#2397 (utoipa, rest-api default) and #2434 (build-side staging).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: keep the Python SessionConfig limitation and name AQE join selection consistently

A SessionConfig passed as config is still used only by the local context;
only cluster_config reaches the cluster, so restore the limitation and point
at cluster_config.

The tuning guide listed join reordering and broadcast join selection as two
optimizations, while the architecture guide calls them one. Both are the
same AQE rule, so merge them into a single Join selection bullet, and say
that the build side is chosen by byte size, falling back to row count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: fix the Spark-functions CLI claim, sample logs, and benchmark formats

- ballista-cli does not get Spark functions from the cluster: SQL is planned
  in the client, so the CLI needs them compiled in. Say so, and give the
  source build that enables them (ballista-core's spark-compat, unified in).
- Replace sample startup logs with what the current binaries print. The
  executor sample showed messages that no longer exist, and a pull-mode poll
  loop although push-staged is the default.
- Keep --format tbl in the benchmark commands; tpch reads dbgen's .tbl files.
- Note that the published images lack keda-scaler, that --disable-rest-api
  turns the REST API off at runtime, and tidy the Prometheus note.
- Stop quoting rust_clippy.sh's exact command, which #2473 changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: move Who uses Ballista from the README to the docs

The README is the project's entry point, so keep the adopters list in the
published docs instead, as a Community page. All three of its links were
checked and resolve.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants