This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git
The following commit(s) were added to refs/heads/main by this push:
new 4f1d7da52 Add TPC-H distributed plan-stability test suite (#1966)
4f1d7da52 is described below
commit 4f1d7da525624f1642efc408bdc241ea6b8999a6
Author: Andy Grove <[email protected]>
AuthorDate: Wed Jul 8 14:00:30 2026 -0600
Add TPC-H distributed plan-stability test suite (#1966)
* test: add dataless stats-injecting table provider for plan stability
* test: add TPC-H fixtures and distributed staged-plan text helper
Add the 8 TPC-H schemas (copied verbatim from benchmarks/src/bin/tpch.rs),
SF100 row-count constants, and staged_plan_text(query_name) which loads a
TPC-H query's SQL, registers stats-only tables, builds the physical plan
with the Ballista-configured planner (target_partitions=16), splits it into
distributed query stages via DefaultDistributedPlanner, and renders the
stages to normalized text. Copy the 22 TPC-H query SQL files and add
helper tests covering a single-statement query (q1) and a multi-statement
query with view DDL (q15).
* test: add TPC-H distributed plan-stability suite with approved plans
* ci: add regenerate script and README for TPC-H plan-stability suite
Add dev/update-tpch-plan-stability.sh to regenerate approved golden
plans, and document the suite's scope, usage, and existing CI
coverage. No workflow changes needed: rust.yml already runs
workspace-wide cargo test/clippy/fmt jobs that pick up the new
[[test]] target automatically.
* style: apply cargo fmt to TPC-H plan-stability test files
Fix outstanding cargo fmt --check deltas in fixtures.rs, stats_table.rs,
and the plan_stability_test! macro invocation in main.rs.
* ci: exclude plan-stability goldens from RAT and add license header
* test: read TPC-H queries from benchmarks/queries instead of copying
---
Cargo.lock | 1 +
ballista/scheduler/Cargo.toml | 5 +
.../scheduler/tests/tpch_plan_stability/README.md | 59 ++++++
.../tests/tpch_plan_stability/approved/q1.txt | 18 ++
.../tests/tpch_plan_stability/approved/q10.txt | 60 ++++++
.../tests/tpch_plan_stability/approved/q11.txt | 87 ++++++++
.../tests/tpch_plan_stability/approved/q12.txt | 30 +++
.../tests/tpch_plan_stability/approved/q13.txt | 32 +++
.../tests/tpch_plan_stability/approved/q14.txt | 25 +++
.../tests/tpch_plan_stability/approved/q15.txt | 57 +++++
.../tests/tpch_plan_stability/approved/q16.txt | 46 +++++
.../tests/tpch_plan_stability/approved/q17.txt | 36 ++++
.../tests/tpch_plan_stability/approved/q18.txt | 46 +++++
.../tests/tpch_plan_stability/approved/q19.txt | 26 +++
.../tests/tpch_plan_stability/approved/q2.txt | 137 ++++++++++++
.../tests/tpch_plan_stability/approved/q20.txt | 69 +++++++
.../tests/tpch_plan_stability/approved/q21.txt | 82 ++++++++
.../tests/tpch_plan_stability/approved/q22.txt | 35 ++++
.../tests/tpch_plan_stability/approved/q3.txt | 40 ++++
.../tests/tpch_plan_stability/approved/q4.txt | 31 +++
.../tests/tpch_plan_stability/approved/q5.txt | 89 ++++++++
.../tests/tpch_plan_stability/approved/q6.txt | 6 +
.../tests/tpch_plan_stability/approved/q7.txt | 89 ++++++++
.../tests/tpch_plan_stability/approved/q8.txt | 118 +++++++++++
.../tests/tpch_plan_stability/approved/q9.txt | 85 ++++++++
.../tests/tpch_plan_stability/fixtures.rs | 229 +++++++++++++++++++++
.../scheduler/tests/tpch_plan_stability/main.rs | 112 ++++++++++
.../tests/tpch_plan_stability/stats_table.rs | 152 ++++++++++++++
dev/release/rat_exclude_files.txt | 1 +
dev/update-tpch-plan-stability.sh | 25 +++
30 files changed, 1828 insertions(+)
diff --git a/Cargo.lock b/Cargo.lock
index 50ee692b8..4d0b4e1d4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1195,6 +1195,7 @@ dependencies = [
"prost",
"prost-types",
"rand 0.10.1",
+ "regex",
"rstest",
"serde",
"tokio",
diff --git a/ballista/scheduler/Cargo.toml b/ballista/scheduler/Cargo.toml
index f882d26f3..92339a5c5 100644
--- a/ballista/scheduler/Cargo.toml
+++ b/ballista/scheduler/Cargo.toml
@@ -79,7 +79,12 @@ tracing-appender = { workspace = true, optional = true }
tracing-subscriber = { workspace = true, optional = true }
uuid = { workspace = true }
+[[test]]
+name = "tpch_plan_stability"
+path = "tests/tpch_plan_stability/main.rs"
+
[dev-dependencies]
+regex = "1"
rstest = { workspace = true }
[build-dependencies]
diff --git a/ballista/scheduler/tests/tpch_plan_stability/README.md
b/ballista/scheduler/tests/tpch_plan_stability/README.md
new file mode 100644
index 000000000..5b62c7f9a
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/README.md
@@ -0,0 +1,59 @@
+<!---
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# TPC-H plan-stability suite
+
+Freezes each TPC-H query's **distributed staged plan** (static planner, SF100
+table statistics, `target_partitions=16`) as an approved text file under
+`approved/`. The suite fails if a code change alters plan shape — join
strategy,
+shuffle/stage boundaries, or broadcast decisions.
+
+- Run: `cargo test -p ballista-scheduler --test tpch_plan_stability`
+- Regenerate after an intended change: `dev/update-tpch-plan-stability.sh`
+ (or `BALLISTA_GENERATE_GOLDEN=1 cargo test -p ballista-scheduler --test
tpch_plan_stability`),
+ then review the diff under `approved/`.
+
+Scope: TPC-H only, static planner, Ballista default config (SortMergeJoin).
+Tables are dataless providers with injected SF100 cardinalities
(`fixtures.rs`).
+
+Query SQL is read directly from the canonical `benchmarks/queries/` at test
time
+(not copied), so a change to a benchmark query flows into the planned plan and
+surfaces as a golden diff, prompting a deliberate regeneration.
+
+## CI coverage
+
+This suite is registered as a `[[test]]` target in
`ballista/scheduler/Cargo.toml`
+(`tpch_plan_stability`), so it already runs wherever CI exercises the
workspace's
+default cargo tests — no dedicated job was added:
+
+- `.github/workflows/rust.yml` → `linux-test` (`cargo test --profile ci
--features=testcontainers`) and `macos-test` (`cargo test --profile ci
--locked`) both run from the workspace root without `-p`/`--workspace`
+ scoping. Since the root `Cargo.toml` sets no `default-members`, this tests
+ every workspace member, including `ballista-scheduler`, which picks up this
+ target automatically.
+- `.github/workflows/rust.yml` → `clippy` already runs `cargo clippy
--all-targets --package ballista-scheduler --all-features -- -D warnings`,
+ which lints this test target too.
+- `.github/workflows/rust.yml` → `lint` runs `cargo fmt --all -- --check`,
+ which covers these files as well.
+
+The generated `approved/*.txt` golden plans carry no license header (the test
+compares their exact bytes), so they are excluded from the Apache RAT license
+check in `.github/workflows/dev.yml` via
+`ballista/scheduler/tests/tpch_plan_stability/approved/*` in
+`dev/release/rat_exclude_files.txt` — mirroring the existing
+`ballista/scheduler/testdata/*` exclusion.
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q1.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q1.txt
new file mode 100644
index 000000000..3f26e2a58
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q1.txt
@@ -0,0 +1,18 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([l_returnflag@0, l_linestatus@1], 16)
+ AggregateExec: mode=Partial, gby=[l_returnflag@5 as l_returnflag,
l_linestatus@6 as l_linestatus], aggr=[sum(lineitem.l_quantity),
sum(lineitem.l_extendedprice), sum(__common_expr_1) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount),
sum(__common_expr_1 * Some(1),20,0 + lineitem.l_tax) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount * Int64(1) +
lineitem.l_tax), avg(lineitem.l_quantity), avg(lineitem.l_extendedprice),
avg(lineitem.l_discount), count(Int64(1))]
+ ProjectionExec: expr=[l_extendedprice@0 * (Some(1),20,0 - l_discount@1) as
__common_expr_1, l_quantity@2 as l_quantity, l_extendedprice@0 as
l_extendedprice, l_discount@1 as l_discount, l_tax@3 as l_tax, l_returnflag@4
as l_returnflag, l_linestatus@5 as l_linestatus]
+ FilterExec: l_shipdate@6 <= 1998-09-02, projection=[l_extendedprice@1,
l_discount@2, l_quantity@0, l_tax@3, l_returnflag@4, l_linestatus@5]
+ StatsExec: rows=600037902
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[l_returnflag@0 ASC NULLS LAST, l_linestatus@1 ASC NULLS
LAST], preserve_partitioning=[true]
+ ProjectionExec: expr=[l_returnflag@0 as l_returnflag, l_linestatus@1 as
l_linestatus, sum(lineitem.l_quantity)@2 as sum_qty,
sum(lineitem.l_extendedprice)@3 as sum_base_price, sum(lineitem.l_extendedprice
* Int64(1) - lineitem.l_discount)@4 as sum_disc_price,
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount * Int64(1) +
lineitem.l_tax)@5 as sum_charge, avg(lineitem.l_quantity)@6 as avg_qty,
avg(lineitem.l_extendedprice)@7 as avg_price, avg(lineitem.l_discount)@8 as
avg_d [...]
+ AggregateExec: mode=FinalPartitioned, gby=[l_returnflag@0 as
l_returnflag, l_linestatus@1 as l_linestatus], aggr=[sum(lineitem.l_quantity),
sum(lineitem.l_extendedprice), sum(__common_expr_1) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount),
sum(__common_expr_1 * Some(1),20,0 + lineitem.l_tax) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount * Int64(1) +
lineitem.l_tax), avg(lineitem.l_quantity), avg(lineitem.l_extendedprice),
avg(lineitem.l_discoun [...]
+ UnresolvedShuffleExec: partitioning: Hash([l_returnflag@0,
l_linestatus@1], 16)
+
+=== Stage 3 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [l_returnflag@0 ASC NULLS LAST, l_linestatus@1 ASC
NULLS LAST]
+ UnresolvedShuffleExec: partitioning: Hash([l_returnflag@0,
l_linestatus@1], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q10.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q10.txt
new file mode 100644
index 000000000..ef52dce9c
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q10.txt
@@ -0,0 +1,60 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ StatsExec: rows=25
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([c_custkey@0], 16)
+ StatsExec: rows=15000000
+
+=== Stage 4 ===
+SortShuffleWriterExec: partitioning=Hash([o_custkey@1], 16)
+ FilterExec: o_orderdate@2 >= 1993-10-01 AND o_orderdate@2 < 1994-01-01,
projection=[o_orderkey@0, o_custkey@1]
+ StatsExec: rows=150000000
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@7], 16)
+ ProjectionExec: expr=[c_custkey@0 as c_custkey, c_name@1 as c_name,
c_address@2 as c_address, c_nationkey@3 as c_nationkey, c_phone@4 as c_phone,
c_acctbal@5 as c_acctbal, c_comment@6 as c_comment, o_orderkey@7 as o_orderkey]
+ SortMergeJoinExec: join_type=Inner, on=[(c_custkey@0, o_custkey@1)]
+ SortExec: expr=[c_custkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0], 16)
+ SortExec: expr=[o_custkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_custkey@1], 16)
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ FilterExec: l_returnflag@3 = R, projection=[l_orderkey@0, l_extendedprice@1,
l_discount@2]
+ StatsExec: rows=600037902
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([c_nationkey@3], 16)
+ ProjectionExec: expr=[c_custkey@0 as c_custkey, c_name@1 as c_name,
c_address@2 as c_address, c_nationkey@3 as c_nationkey, c_phone@4 as c_phone,
c_acctbal@5 as c_acctbal, c_comment@6 as c_comment, l_extendedprice@9 as
l_extendedprice, l_discount@10 as l_discount]
+ SortMergeJoinExec: join_type=Inner, on=[(o_orderkey@7, l_orderkey@0)]
+ SortExec: expr=[o_orderkey@7 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@7], 16)
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+
+=== Stage 8 ===
+SortShuffleWriterExec: partitioning=Hash([c_custkey@0, c_name@1, c_acctbal@2,
c_phone@3, n_name@4, c_address@5, c_comment@6], 16)
+ AggregateExec: mode=Partial, gby=[c_custkey@0 as c_custkey, c_name@1 as
c_name, c_acctbal@4 as c_acctbal, c_phone@3 as c_phone, n_name@8 as n_name,
c_address@2 as c_address, c_comment@5 as c_comment],
aggr=[sum(lineitem.l_extendedprice * Some(1),20,0 - lineitem.l_discount) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)]
+ ProjectionExec: expr=[c_custkey@0 as c_custkey, c_name@1 as c_name,
c_address@2 as c_address, c_phone@4 as c_phone, c_acctbal@5 as c_acctbal,
c_comment@6 as c_comment, l_extendedprice@7 as l_extendedprice, l_discount@8 as
l_discount, n_name@10 as n_name]
+ ProjectionExec: expr=[c_custkey@2 as c_custkey, c_name@3 as c_name,
c_address@4 as c_address, c_nationkey@5 as c_nationkey, c_phone@6 as c_phone,
c_acctbal@7 as c_acctbal, c_comment@8 as c_comment, l_extendedprice@9 as
l_extendedprice, l_discount@10 as l_discount, n_nationkey@0 as n_nationkey,
n_name@1 as n_name]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
c_nationkey@3)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([c_nationkey@3], 16)
+
+=== Stage 9 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: TopK(fetch=20), expr=[revenue@2 DESC], preserve_partitioning=[true]
+ ProjectionExec: expr=[c_custkey@0 as c_custkey, c_name@1 as c_name,
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)@7 as revenue,
c_acctbal@2 as c_acctbal, n_name@4 as n_name, c_address@5 as c_address,
c_phone@3 as c_phone, c_comment@6 as c_comment]
+ AggregateExec: mode=FinalPartitioned, gby=[c_custkey@0 as c_custkey,
c_name@1 as c_name, c_acctbal@2 as c_acctbal, c_phone@3 as c_phone, n_name@4 as
n_name, c_address@5 as c_address, c_comment@6 as c_comment],
aggr=[sum(lineitem.l_extendedprice * Some(1),20,0 - lineitem.l_discount) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)]
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0, c_name@1,
c_acctbal@2, c_phone@3, n_name@4, c_address@5, c_comment@6], 16)
+
+=== Stage 10 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [revenue@2 DESC], fetch=20
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0, c_name@1,
c_acctbal@3, c_phone@6, n_name@4, c_address@5, c_comment@7], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q11.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q11.txt
new file mode 100644
index 000000000..059c25242
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q11.txt
@@ -0,0 +1,87 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ FilterExec: n_name@1 = GERMANY, projection=[n_nationkey@0]
+ StatsExec: rows=25
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([ps_suppkey@0], 16)
+ StatsExec: rows=80000000
+
+=== Stage 4 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ StatsExec: rows=1000000
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@2], 16)
+ ProjectionExec: expr=[ps_availqty@1 as ps_availqty, ps_supplycost@2 as
ps_supplycost, s_nationkey@4 as s_nationkey]
+ SortMergeJoinExec: join_type=Inner, on=[(ps_suppkey@0, s_suppkey@0)]
+ SortExec: expr=[ps_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_suppkey@0], 16)
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+
+=== Stage 6 ===
+ShuffleWriterExec: partitioning: None
+ AggregateExec: mode=Partial, gby=[], aggr=[sum(partsupp.ps_supplycost *
partsupp.ps_availqty)]
+ ProjectionExec: expr=[ps_availqty@0 as ps_availqty, ps_supplycost@1 as
ps_supplycost]
+ ProjectionExec: expr=[ps_availqty@1 as ps_availqty, ps_supplycost@2 as
ps_supplycost, s_nationkey@3 as s_nationkey, n_nationkey@0 as n_nationkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
s_nationkey@2)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@2], 16)
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ FilterExec: n_name@1 = GERMANY, projection=[n_nationkey@0]
+ StatsExec: rows=25
+
+=== Stage 8 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 9 ===
+SortShuffleWriterExec: partitioning=Hash([ps_suppkey@1], 16)
+ StatsExec: rows=80000000
+
+=== Stage 10 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ StatsExec: rows=1000000
+
+=== Stage 11 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@3], 16)
+ ProjectionExec: expr=[ps_partkey@0 as ps_partkey, ps_availqty@2 as
ps_availqty, ps_supplycost@3 as ps_supplycost, s_nationkey@5 as s_nationkey]
+ SortMergeJoinExec: join_type=Inner, on=[(ps_suppkey@1, s_suppkey@0)]
+ SortExec: expr=[ps_suppkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_suppkey@1], 16)
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+
+=== Stage 12 ===
+SortShuffleWriterExec: partitioning=Hash([ps_partkey@0], 16)
+ AggregateExec: mode=Partial, gby=[ps_partkey@0 as ps_partkey],
aggr=[sum(partsupp.ps_supplycost * partsupp.ps_availqty)]
+ ProjectionExec: expr=[ps_partkey@0 as ps_partkey, ps_availqty@1 as
ps_availqty, ps_supplycost@2 as ps_supplycost]
+ ProjectionExec: expr=[ps_partkey@1 as ps_partkey, ps_availqty@2 as
ps_availqty, ps_supplycost@3 as ps_supplycost, s_nationkey@4 as s_nationkey,
n_nationkey@0 as n_nationkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
s_nationkey@3)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@3], 16)
+
+=== Stage 13 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[value@1 DESC], preserve_partitioning=[true]
+ ProjectionExec: expr=[ps_partkey@1 as ps_partkey,
sum(partsupp.ps_supplycost * partsupp.ps_availqty)@2 as value]
+ NestedLoopJoinExec: join_type=Inner, filter=join_proj_push_down_1@1 >
sum(partsupp.ps_supplycost * partsupp.ps_availqty) * Float64(0.0001)@0,
projection=[sum(partsupp.ps_supplycost * partsupp.ps_availqty) *
Float64(0.0001)@0, ps_partkey@1, sum(partsupp.ps_supplycost *
partsupp.ps_availqty)@2]
+ ProjectionExec: expr=[CAST(CAST(sum(partsupp.ps_supplycost *
partsupp.ps_availqty)@0 AS Float64) * 0.0001 AS Decimal128(38, 15)) as
sum(partsupp.ps_supplycost * partsupp.ps_availqty) * Float64(0.0001)]
+ AggregateExec: mode=Final, gby=[], aggr=[sum(partsupp.ps_supplycost
* partsupp.ps_availqty)]
+ CoalescePartitionsExec
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@2], 16)
+ ProjectionExec: expr=[ps_partkey@0 as ps_partkey,
sum(partsupp.ps_supplycost * partsupp.ps_availqty)@1 as
sum(partsupp.ps_supplycost * partsupp.ps_availqty),
CAST(sum(partsupp.ps_supplycost * partsupp.ps_availqty)@1 AS Decimal128(38,
15)) as join_proj_push_down_1]
+ AggregateExec: mode=FinalPartitioned, gby=[ps_partkey@0 as
ps_partkey], aggr=[sum(partsupp.ps_supplycost * partsupp.ps_availqty)]
+ UnresolvedShuffleExec: partitioning: Hash([ps_partkey@0], 16)
+
+=== Stage 14 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [value@1 DESC]
+ UnresolvedShuffleExec: partitioning: Hash([ps_partkey@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q12.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q12.txt
new file mode 100644
index 000000000..9802dd3a7
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q12.txt
@@ -0,0 +1,30 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ FilterExec: (l_shipmode@4 = MAIL OR l_shipmode@4 = SHIP) AND l_receiptdate@3
> l_commitdate@2 AND l_shipdate@1 < l_commitdate@2 AND l_receiptdate@3 >=
1994-01-01 AND l_receiptdate@3 < 1995-01-01, projection=[l_orderkey@0,
l_shipmode@4]
+ StatsExec: rows=600037902
+
+=== Stage 2 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@0], 16)
+ StatsExec: rows=150000000
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([l_shipmode@0], 16)
+ AggregateExec: mode=Partial, gby=[l_shipmode@0 as l_shipmode],
aggr=[sum(CASE WHEN orders.o_orderpriority = Utf8("1-URGENT") OR
orders.o_orderpriority = Utf8("2-HIGH") THEN Int64(1) ELSE Int64(0) END),
sum(CASE WHEN orders.o_orderpriority != Utf8("1-URGENT") AND
orders.o_orderpriority != Utf8("2-HIGH") THEN Int64(1) ELSE Int64(0) END)]
+ ProjectionExec: expr=[l_shipmode@1 as l_shipmode, o_orderpriority@3 as
o_orderpriority]
+ SortMergeJoinExec: join_type=Inner, on=[(l_orderkey@0, o_orderkey@0)]
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+ SortExec: expr=[o_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@0], 16)
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[l_shipmode@0 ASC NULLS LAST], preserve_partitioning=[true]
+ ProjectionExec: expr=[l_shipmode@0 as l_shipmode, sum(CASE WHEN
orders.o_orderpriority = Utf8("1-URGENT") OR orders.o_orderpriority =
Utf8("2-HIGH") THEN Int64(1) ELSE Int64(0) END)@1 as high_line_count, sum(CASE
WHEN orders.o_orderpriority != Utf8("1-URGENT") AND orders.o_orderpriority !=
Utf8("2-HIGH") THEN Int64(1) ELSE Int64(0) END)@2 as low_line_count]
+ AggregateExec: mode=FinalPartitioned, gby=[l_shipmode@0 as l_shipmode],
aggr=[sum(CASE WHEN orders.o_orderpriority = Utf8("1-URGENT") OR
orders.o_orderpriority = Utf8("2-HIGH") THEN Int64(1) ELSE Int64(0) END),
sum(CASE WHEN orders.o_orderpriority != Utf8("1-URGENT") AND
orders.o_orderpriority != Utf8("2-HIGH") THEN Int64(1) ELSE Int64(0) END)]
+ UnresolvedShuffleExec: partitioning: Hash([l_shipmode@0], 16)
+
+=== Stage 5 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [l_shipmode@0 ASC NULLS LAST]
+ UnresolvedShuffleExec: partitioning: Hash([l_shipmode@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q13.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q13.txt
new file mode 100644
index 000000000..252b08f08
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q13.txt
@@ -0,0 +1,32 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([c_custkey@0], 16)
+ StatsExec: rows=15000000
+
+=== Stage 2 ===
+SortShuffleWriterExec: partitioning=Hash([o_custkey@1], 16)
+ FilterExec: o_comment@2 NOT LIKE %special%requests%,
projection=[o_orderkey@0, o_custkey@1]
+ StatsExec: rows=150000000
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([c_count@0], 16)
+ AggregateExec: mode=Partial, gby=[c_count@0 as c_count],
aggr=[count(Int64(1))]
+ ProjectionExec: expr=[count(orders.o_orderkey)@1 as c_count]
+ AggregateExec: mode=SinglePartitioned, gby=[c_custkey@0 as c_custkey],
aggr=[count(orders.o_orderkey)], ordering_mode=Sorted
+ ProjectionExec: expr=[c_custkey@0 as c_custkey, o_orderkey@1 as
o_orderkey]
+ SortMergeJoinExec: join_type=Left, on=[(c_custkey@0, o_custkey@1)]
+ SortExec: expr=[c_custkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0], 16)
+ SortExec: expr=[o_custkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_custkey@1], 16)
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[custdist@1 DESC, c_count@0 DESC],
preserve_partitioning=[true]
+ ProjectionExec: expr=[c_count@0 as c_count, count(Int64(1))@1 as custdist]
+ AggregateExec: mode=FinalPartitioned, gby=[c_count@0 as c_count],
aggr=[count(Int64(1))]
+ UnresolvedShuffleExec: partitioning: Hash([c_count@0], 16)
+
+=== Stage 5 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [custdist@1 DESC, c_count@0 DESC]
+ UnresolvedShuffleExec: partitioning: Hash([c_count@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q14.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q14.txt
new file mode 100644
index 000000000..172add80c
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q14.txt
@@ -0,0 +1,25 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([l_partkey@0], 16)
+ FilterExec: l_shipdate@3 >= 1995-09-01 AND l_shipdate@3 < 1995-10-01,
projection=[l_partkey@0, l_extendedprice@1, l_discount@2]
+ StatsExec: rows=600037902
+
+=== Stage 2 ===
+SortShuffleWriterExec: partitioning=Hash([p_partkey@0], 16)
+ StatsExec: rows=20000000
+
+=== Stage 3 ===
+ShuffleWriterExec: partitioning: None
+ AggregateExec: mode=Partial, gby=[], aggr=[sum(CASE WHEN part.p_type LIKE
PROMO% THEN __common_expr_1 ELSE Some(0),38,4 END) as sum(CASE WHEN part.p_type
LIKE Utf8("PROMO%") THEN lineitem.l_extendedprice * Int64(1) -
lineitem.l_discount ELSE Int64(0) END), sum(__common_expr_1) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)]
+ ProjectionExec: expr=[l_extendedprice@1 * (Some(1),20,0 - l_discount@2) as
__common_expr_1, p_type@4 as p_type]
+ SortMergeJoinExec: join_type=Inner, on=[(l_partkey@0, p_partkey@0)]
+ SortExec: expr=[l_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_partkey@0], 16)
+ SortExec: expr=[p_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@0], 16)
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ ProjectionExec: expr=[100 * CAST(sum(CASE WHEN part.p_type LIKE
Utf8("PROMO%") THEN lineitem.l_extendedprice * Int64(1) - lineitem.l_discount
ELSE Int64(0) END)@0 AS Float64) / CAST(sum(lineitem.l_extendedprice * Int64(1)
- lineitem.l_discount)@1 AS Float64) as promo_revenue]
+ AggregateExec: mode=Final, gby=[], aggr=[sum(CASE WHEN part.p_type LIKE
PROMO% THEN __common_expr_1 ELSE Some(0),38,4 END) as sum(CASE WHEN part.p_type
LIKE Utf8("PROMO%") THEN lineitem.l_extendedprice * Int64(1) -
lineitem.l_discount ELSE Int64(0) END), sum(__common_expr_1) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)]
+ CoalescePartitionsExec
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@3], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q15.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q15.txt
new file mode 100644
index 000000000..2a357f08d
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q15.txt
@@ -0,0 +1,57 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([l_suppkey@0], 16)
+ AggregateExec: mode=Partial, gby=[l_suppkey@0 as l_suppkey],
aggr=[sum(lineitem.l_extendedprice * Some(1),20,0 - lineitem.l_discount) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)]
+ FilterExec: l_shipdate@3 >= 1996-01-01 AND l_shipdate@3 < 1996-04-01,
projection=[l_suppkey@0, l_extendedprice@1, l_discount@2]
+ StatsExec: rows=600037902
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ AggregateExec: mode=Partial, gby=[], aggr=[max(revenue0.total_revenue)]
+ ProjectionExec: expr=[sum(lineitem.l_extendedprice * Int64(1) -
lineitem.l_discount)@1 as total_revenue]
+ AggregateExec: mode=FinalPartitioned, gby=[l_suppkey@0 as l_suppkey],
aggr=[sum(lineitem.l_extendedprice * Some(1),20,0 - lineitem.l_discount) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)]
+ UnresolvedShuffleExec: partitioning: Hash([l_suppkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([max(revenue0.total_revenue)@0], 16)
+ AggregateExec: mode=Final, gby=[], aggr=[max(revenue0.total_revenue)]
+ CoalescePartitionsExec
+ UnresolvedShuffleExec: partitioning: Hash([l_suppkey@0], 16)
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([max(revenue0.total_revenue)@0],
16)
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ StatsExec: rows=1000000
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([l_suppkey@0], 16)
+ AggregateExec: mode=Partial, gby=[l_suppkey@0 as l_suppkey],
aggr=[sum(lineitem.l_extendedprice * Some(1),20,0 - lineitem.l_discount) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)]
+ FilterExec: l_shipdate@3 >= 1996-01-01 AND l_shipdate@3 < 1996-04-01,
projection=[l_suppkey@0, l_extendedprice@1, l_discount@2]
+ StatsExec: rows=600037902
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([total_revenue@4], 16)
+ ProjectionExec: expr=[s_suppkey@0 as s_suppkey, s_name@1 as s_name,
s_address@2 as s_address, s_phone@3 as s_phone, total_revenue@5 as
total_revenue]
+ SortMergeJoinExec: join_type=Inner, on=[(s_suppkey@0, supplier_no@0)]
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+ SortExec: expr=[supplier_no@0 ASC], preserve_partitioning=[true]
+ ProjectionExec: expr=[l_suppkey@0 as supplier_no,
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)@1 as
total_revenue]
+ AggregateExec: mode=FinalPartitioned, gby=[l_suppkey@0 as
l_suppkey], aggr=[sum(lineitem.l_extendedprice * Some(1),20,0 -
lineitem.l_discount) as sum(lineitem.l_extendedprice * Int64(1) -
lineitem.l_discount)]
+ UnresolvedShuffleExec: partitioning: Hash([l_suppkey@0], 16)
+
+=== Stage 8 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[s_suppkey@0 ASC NULLS LAST], preserve_partitioning=[true]
+ ProjectionExec: expr=[s_suppkey@0 as s_suppkey, s_name@1 as s_name,
s_address@2 as s_address, s_phone@3 as s_phone, total_revenue@4 as
total_revenue]
+ ProjectionExec: expr=[s_suppkey@1 as s_suppkey, s_name@2 as s_name,
s_address@3 as s_address, s_phone@4 as s_phone, total_revenue@5 as
total_revenue, max(revenue0.total_revenue)@0 as max(revenue0.total_revenue)]
+ HashJoinExec: mode=CollectLeft, join_type=Inner,
on=[(max(revenue0.total_revenue)@0, total_revenue@4)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([total_revenue@4], 16)
+
+=== Stage 9 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [s_suppkey@0 ASC NULLS LAST]
+ UnresolvedShuffleExec: partitioning: Hash([total_revenue@4], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q16.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q16.txt
new file mode 100644
index 000000000..4403c3587
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q16.txt
@@ -0,0 +1,46 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ FilterExec: s_comment@1 LIKE %Customer%Complaints%, projection=[s_suppkey@0]
+ StatsExec: rows=1000000
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([ps_partkey@0], 16)
+ StatsExec: rows=80000000
+
+=== Stage 4 ===
+SortShuffleWriterExec: partitioning=Hash([p_partkey@0], 16)
+ FilterExec: p_brand@1 != Brand#45 AND p_type@2 NOT LIKE MEDIUM POLISHED% AND
p_size@3 IN (SET) ([49, 14, 23, 45, 19, 3, 36, 9])
+ StatsExec: rows=20000000
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([ps_suppkey@0], 16)
+ ProjectionExec: expr=[ps_suppkey@1 as ps_suppkey, p_brand@3 as p_brand,
p_type@4 as p_type, p_size@5 as p_size]
+ SortMergeJoinExec: join_type=Inner, on=[(ps_partkey@0, p_partkey@0)]
+ SortExec: expr=[ps_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_partkey@0], 16)
+ SortExec: expr=[p_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@0], 16)
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([p_brand@0, p_type@1, p_size@2], 16)
+ AggregateExec: mode=Partial, gby=[p_brand@0 as p_brand, p_type@1 as p_type,
p_size@2 as p_size], aggr=[count(alias1)]
+ AggregateExec: mode=SinglePartitioned, gby=[p_brand@1 as p_brand, p_type@2
as p_type, p_size@3 as p_size, ps_suppkey@0 as alias1], aggr=[]
+ HashJoinExec: mode=CollectLeft, join_type=RightAnti, on=[(s_suppkey@0,
ps_suppkey@0)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([ps_suppkey@0], 16)
+
+=== Stage 7 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[supplier_cnt@3 DESC, p_brand@0 ASC NULLS LAST, p_type@1 ASC
NULLS LAST, p_size@2 ASC NULLS LAST], preserve_partitioning=[true]
+ ProjectionExec: expr=[p_brand@0 as p_brand, p_type@1 as p_type, p_size@2
as p_size, count(alias1)@3 as supplier_cnt]
+ AggregateExec: mode=FinalPartitioned, gby=[p_brand@0 as p_brand,
p_type@1 as p_type, p_size@2 as p_size], aggr=[count(alias1)]
+ UnresolvedShuffleExec: partitioning: Hash([p_brand@0, p_type@1,
p_size@2], 16)
+
+=== Stage 8 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [supplier_cnt@3 DESC, p_brand@0 ASC NULLS LAST,
p_type@1 ASC NULLS LAST, p_size@2 ASC NULLS LAST]
+ UnresolvedShuffleExec: partitioning: Hash([p_brand@0, p_type@1, p_size@2],
16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q17.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q17.txt
new file mode 100644
index 000000000..e3b6744bb
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q17.txt
@@ -0,0 +1,36 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([l_partkey@0], 16)
+ StatsExec: rows=600037902
+
+=== Stage 2 ===
+SortShuffleWriterExec: partitioning=Hash([p_partkey@0], 16)
+ FilterExec: p_brand@1 = Brand#23 AND p_container@2 = MED BOX,
projection=[p_partkey@0]
+ StatsExec: rows=20000000
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([l_partkey@0], 16)
+ AggregateExec: mode=Partial, gby=[l_partkey@0 as l_partkey],
aggr=[avg(lineitem.l_quantity)]
+ StatsExec: rows=600037902
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ AggregateExec: mode=Partial, gby=[], aggr=[sum(lineitem.l_extendedprice)]
+ ProjectionExec: expr=[l_extendedprice@1 as l_extendedprice]
+ SortMergeJoinExec: join_type=Inner, on=[(p_partkey@2, l_partkey@1)],
filter=CAST(l_quantity@0 AS Decimal128(30, 15)) < Float64(0.2) *
avg(lineitem.l_quantity)@1
+ ProjectionExec: expr=[l_quantity@1 as l_quantity, l_extendedprice@2 as
l_extendedprice, p_partkey@3 as p_partkey]
+ SortMergeJoinExec: join_type=Inner, on=[(l_partkey@0, p_partkey@0)]
+ SortExec: expr=[l_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_partkey@0], 16)
+ SortExec: expr=[p_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@0], 16)
+ SortExec: expr=[l_partkey@1 ASC], preserve_partitioning=[true]
+ ProjectionExec: expr=[CAST(0.2 * CAST(avg(lineitem.l_quantity)@1 AS
Float64) AS Decimal128(30, 15)) as Float64(0.2) * avg(lineitem.l_quantity),
l_partkey@0 as l_partkey]
+ AggregateExec: mode=FinalPartitioned, gby=[l_partkey@0 as
l_partkey], aggr=[avg(lineitem.l_quantity)]
+ UnresolvedShuffleExec: partitioning: Hash([l_partkey@0], 16)
+
+=== Stage 5 ===
+ShuffleWriterExec: partitioning: None
+ ProjectionExec: expr=[CAST(sum(lineitem.l_extendedprice)@0 AS Float64) / 7
as avg_yearly]
+ AggregateExec: mode=Final, gby=[], aggr=[sum(lineitem.l_extendedprice)]
+ CoalescePartitionsExec
+ UnresolvedShuffleExec: partitioning: Hash([l_partkey@4], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q18.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q18.txt
new file mode 100644
index 000000000..190add99b
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q18.txt
@@ -0,0 +1,46 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([c_custkey@0], 16)
+ StatsExec: rows=15000000
+
+=== Stage 2 ===
+SortShuffleWriterExec: partitioning=Hash([o_custkey@1], 16)
+ StatsExec: rows=150000000
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@2], 16)
+ ProjectionExec: expr=[c_custkey@0 as c_custkey, c_name@1 as c_name,
o_orderkey@2 as o_orderkey, o_totalprice@4 as o_totalprice, o_orderdate@5 as
o_orderdate]
+ SortMergeJoinExec: join_type=Inner, on=[(c_custkey@0, o_custkey@1)]
+ SortExec: expr=[c_custkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0], 16)
+ SortExec: expr=[o_custkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_custkey@1], 16)
+
+=== Stage 4 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ StatsExec: rows=600037902
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ AggregateExec: mode=Partial, gby=[l_orderkey@0 as l_orderkey],
aggr=[sum(lineitem.l_quantity)]
+ StatsExec: rows=600037902
+
+=== Stage 6 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: TopK(fetch=100), expr=[o_totalprice@4 DESC, o_orderdate@3 ASC
NULLS LAST], preserve_partitioning=[true]
+ AggregateExec: mode=SinglePartitioned, gby=[c_name@1 as c_name,
c_custkey@0 as c_custkey, o_orderkey@2 as o_orderkey, o_orderdate@4 as
o_orderdate, o_totalprice@3 as o_totalprice], aggr=[sum(lineitem.l_quantity)],
ordering_mode=PartiallySorted([2])
+ SortMergeJoinExec: join_type=LeftSemi, on=[(o_orderkey@2, l_orderkey@0)]
+ ProjectionExec: expr=[c_custkey@0 as c_custkey, c_name@1 as c_name,
o_orderkey@2 as o_orderkey, o_totalprice@3 as o_totalprice, o_orderdate@4 as
o_orderdate, l_quantity@6 as l_quantity]
+ SortMergeJoinExec: join_type=Inner, on=[(o_orderkey@2, l_orderkey@0)]
+ SortExec: expr=[o_orderkey@2 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@2], 16)
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ FilterExec: sum(lineitem.l_quantity)@1 > Some(30000),25,2,
projection=[l_orderkey@0]
+ AggregateExec: mode=FinalPartitioned, gby=[l_orderkey@0 as
l_orderkey], aggr=[sum(lineitem.l_quantity)]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+
+=== Stage 7 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [o_totalprice@4 DESC, o_orderdate@3 ASC NULLS
LAST], fetch=100
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@2], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q19.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q19.txt
new file mode 100644
index 000000000..a3960ed6e
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q19.txt
@@ -0,0 +1,26 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([l_partkey@0], 16)
+ FilterExec: (l_shipmode@5 = AIR OR l_shipmode@5 = AIR REG) AND
l_shipinstruct@4 = DELIVER IN PERSON AND (l_quantity@1 >= Some(100),15,2 AND
l_quantity@1 <= Some(1100),15,2 OR l_quantity@1 >= Some(1000),15,2 AND
l_quantity@1 <= Some(2000),15,2 OR l_quantity@1 >= Some(2000),15,2 AND
l_quantity@1 <= Some(3000),15,2), projection=[l_partkey@0, l_quantity@1,
l_extendedprice@2, l_discount@3]
+ StatsExec: rows=600037902
+
+=== Stage 2 ===
+SortShuffleWriterExec: partitioning=Hash([p_partkey@0], 16)
+ FilterExec: p_size@2 >= 1 AND (p_brand@1 = Brand#12 AND p_container@3 IN
(SET) ([SM CASE, SM BOX, SM PACK, SM PKG]) AND p_size@2 <= 5 OR p_brand@1 =
Brand#23 AND p_container@3 IN (SET) ([MED BAG, MED BOX, MED PKG, MED PACK]) AND
p_size@2 <= 10 OR p_brand@1 = Brand#34 AND p_container@3 IN (SET) ([LG CASE, LG
BOX, LG PACK, LG PKG]) AND p_size@2 <= 15)
+ StatsExec: rows=20000000
+
+=== Stage 3 ===
+ShuffleWriterExec: partitioning: None
+ AggregateExec: mode=Partial, gby=[], aggr=[sum(lineitem.l_extendedprice *
Some(1),20,0 - lineitem.l_discount) as sum(lineitem.l_extendedprice * Int64(1)
- lineitem.l_discount)]
+ ProjectionExec: expr=[l_extendedprice@2 as l_extendedprice, l_discount@3
as l_discount]
+ SortMergeJoinExec: join_type=Inner, on=[(l_partkey@0, p_partkey@0)],
filter=p_brand@1 = Brand#12 AND p_container@3 IN (SET) ([SM CASE, SM BOX, SM
PACK, SM PKG]) AND l_quantity@0 >= Some(100),15,2 AND l_quantity@0 <=
Some(1100),15,2 AND p_size@2 <= 5 OR p_brand@1 = Brand#23 AND p_container@3 IN
(SET) ([MED BAG, MED BOX, MED PKG, MED PACK]) AND l_quantity@0 >=
Some(1000),15,2 AND l_quantity@0 <= Some(2000),15,2 AND p_size@2 <= 10 OR
p_brand@1 = Brand#34 AND p_container@3 IN (SET) ([L [...]
+ SortExec: expr=[l_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_partkey@0], 16)
+ SortExec: expr=[p_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@0], 16)
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ ProjectionExec: expr=[sum(lineitem.l_extendedprice * Int64(1) -
lineitem.l_discount)@0 as revenue]
+ AggregateExec: mode=Final, gby=[], aggr=[sum(lineitem.l_extendedprice *
Some(1),20,0 - lineitem.l_discount) as sum(lineitem.l_extendedprice * Int64(1)
- lineitem.l_discount)]
+ CoalescePartitionsExec
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@4], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q2.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q2.txt
new file mode 100644
index 000000000..5d3dba459
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q2.txt
@@ -0,0 +1,137 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([r_regionkey@0], 16)
+ FilterExec: r_name@1 = EUROPE, projection=[r_regionkey@0]
+ StatsExec: rows=5
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([r_regionkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ StatsExec: rows=25
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([p_partkey@0], 16)
+ FilterExec: p_size@3 = 15 AND p_type@2 LIKE %BRASS, projection=[p_partkey@0,
p_mfgr@1]
+ StatsExec: rows=20000000
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([ps_partkey@0], 16)
+ StatsExec: rows=80000000
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([ps_suppkey@2], 16)
+ ProjectionExec: expr=[p_partkey@0 as p_partkey, p_mfgr@1 as p_mfgr,
ps_suppkey@3 as ps_suppkey, ps_supplycost@4 as ps_supplycost]
+ SortMergeJoinExec: join_type=Inner, on=[(p_partkey@0, ps_partkey@0)]
+ SortExec: expr=[p_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@0], 16)
+ SortExec: expr=[ps_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_partkey@0], 16)
+
+=== Stage 8 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ StatsExec: rows=1000000
+
+=== Stage 9 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@4], 16)
+ ProjectionExec: expr=[p_partkey@0 as p_partkey, p_mfgr@1 as p_mfgr, s_name@5
as s_name, s_address@6 as s_address, s_nationkey@7 as s_nationkey, s_phone@8 as
s_phone, s_acctbal@9 as s_acctbal, s_comment@10 as s_comment, ps_supplycost@3
as ps_supplycost]
+ SortMergeJoinExec: join_type=Inner, on=[(ps_suppkey@2, s_suppkey@0)]
+ SortExec: expr=[ps_suppkey@2 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_suppkey@2], 16)
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+
+=== Stage 10 ===
+SortShuffleWriterExec: partitioning=Hash([n_regionkey@9], 16)
+ ProjectionExec: expr=[p_partkey@0 as p_partkey, p_mfgr@1 as p_mfgr, s_name@2
as s_name, s_address@3 as s_address, s_phone@5 as s_phone, s_acctbal@6 as
s_acctbal, s_comment@7 as s_comment, ps_supplycost@8 as ps_supplycost,
n_name@10 as n_name, n_regionkey@11 as n_regionkey]
+ ProjectionExec: expr=[p_partkey@3 as p_partkey, p_mfgr@4 as p_mfgr,
s_name@5 as s_name, s_address@6 as s_address, s_nationkey@7 as s_nationkey,
s_phone@8 as s_phone, s_acctbal@9 as s_acctbal, s_comment@10 as s_comment,
ps_supplycost@11 as ps_supplycost, n_nationkey@0 as n_nationkey, n_name@1 as
n_name, n_regionkey@2 as n_regionkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
s_nationkey@4)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@4], 16)
+
+=== Stage 11 ===
+SortShuffleWriterExec: partitioning=Hash([p_partkey@0, ps_supplycost@7], 16)
+ ProjectionExec: expr=[p_partkey@0 as p_partkey, p_mfgr@1 as p_mfgr, s_name@2
as s_name, s_address@3 as s_address, s_phone@4 as s_phone, s_acctbal@5 as
s_acctbal, s_comment@6 as s_comment, ps_supplycost@7 as ps_supplycost, n_name@8
as n_name]
+ ProjectionExec: expr=[p_partkey@1 as p_partkey, p_mfgr@2 as p_mfgr,
s_name@3 as s_name, s_address@4 as s_address, s_phone@5 as s_phone, s_acctbal@6
as s_acctbal, s_comment@7 as s_comment, ps_supplycost@8 as ps_supplycost,
n_name@9 as n_name, n_regionkey@10 as n_regionkey, r_regionkey@0 as r_regionkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(r_regionkey@0,
n_regionkey@9)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([n_regionkey@9], 16)
+
+=== Stage 12 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@0, ps_supplycost@7], 16)
+
+=== Stage 13 ===
+SortShuffleWriterExec: partitioning=Hash([r_regionkey@0], 16)
+ FilterExec: r_name@1 = EUROPE, projection=[r_regionkey@0]
+ StatsExec: rows=5
+
+=== Stage 14 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([r_regionkey@0], 16)
+
+=== Stage 15 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ StatsExec: rows=25
+
+=== Stage 16 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 17 ===
+SortShuffleWriterExec: partitioning=Hash([ps_suppkey@1], 16)
+ StatsExec: rows=80000000
+
+=== Stage 18 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ StatsExec: rows=1000000
+
+=== Stage 19 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@2], 16)
+ ProjectionExec: expr=[ps_partkey@0 as ps_partkey, ps_supplycost@2 as
ps_supplycost, s_nationkey@4 as s_nationkey]
+ SortMergeJoinExec: join_type=Inner, on=[(ps_suppkey@1, s_suppkey@0)]
+ SortExec: expr=[ps_suppkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_suppkey@1], 16)
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+
+=== Stage 20 ===
+SortShuffleWriterExec: partitioning=Hash([n_regionkey@2], 16)
+ ProjectionExec: expr=[ps_partkey@0 as ps_partkey, ps_supplycost@1 as
ps_supplycost, n_regionkey@4 as n_regionkey]
+ ProjectionExec: expr=[ps_partkey@2 as ps_partkey, ps_supplycost@3 as
ps_supplycost, s_nationkey@4 as s_nationkey, n_nationkey@0 as n_nationkey,
n_regionkey@1 as n_regionkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
s_nationkey@2)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@2], 16)
+
+=== Stage 21 ===
+SortShuffleWriterExec: partitioning=Hash([ps_partkey@0], 16)
+ AggregateExec: mode=Partial, gby=[ps_partkey@0 as ps_partkey],
aggr=[min(partsupp.ps_supplycost)]
+ ProjectionExec: expr=[ps_partkey@0 as ps_partkey, ps_supplycost@1 as
ps_supplycost]
+ ProjectionExec: expr=[ps_partkey@1 as ps_partkey, ps_supplycost@2 as
ps_supplycost, n_regionkey@3 as n_regionkey, r_regionkey@0 as r_regionkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(r_regionkey@0,
n_regionkey@2)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([n_regionkey@2], 16)
+
+=== Stage 22 ===
+SortShuffleWriterExec: partitioning=Hash([ps_partkey@1,
min(partsupp.ps_supplycost)@0], 16)
+ ProjectionExec: expr=[min(partsupp.ps_supplycost)@1 as
min(partsupp.ps_supplycost), ps_partkey@0 as ps_partkey]
+ AggregateExec: mode=FinalPartitioned, gby=[ps_partkey@0 as ps_partkey],
aggr=[min(partsupp.ps_supplycost)]
+ UnresolvedShuffleExec: partitioning: Hash([ps_partkey@0], 16)
+
+=== Stage 23 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: TopK(fetch=100), expr=[s_acctbal@0 DESC, n_name@2 ASC NULLS LAST,
s_name@1 ASC NULLS LAST, p_partkey@3 ASC NULLS LAST],
preserve_partitioning=[true]
+ ProjectionExec: expr=[s_acctbal@5 as s_acctbal, s_name@2 as s_name,
n_name@8 as n_name, p_partkey@0 as p_partkey, p_mfgr@1 as p_mfgr, s_address@3
as s_address, s_phone@4 as s_phone, s_comment@6 as s_comment]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(p_partkey@0,
ps_partkey@1), (ps_supplycost@7, min(partsupp.ps_supplycost)@0)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([ps_partkey@1,
min(partsupp.ps_supplycost)@0], 16)
+
+=== Stage 24 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [s_acctbal@0 DESC, n_name@2 ASC NULLS LAST,
s_name@1 ASC NULLS LAST, p_partkey@3 ASC NULLS LAST], fetch=100
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@3,
min(partsupp.ps_supplycost)@9], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q20.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q20.txt
new file mode 100644
index 000000000..a684c1a4d
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q20.txt
@@ -0,0 +1,69 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ FilterExec: n_name@1 = CANADA, projection=[n_nationkey@0]
+ StatsExec: rows=25
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@3], 16)
+ StatsExec: rows=1000000
+
+=== Stage 4 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ ProjectionExec: expr=[s_suppkey@0 as s_suppkey, s_name@1 as s_name,
s_address@2 as s_address]
+ ProjectionExec: expr=[s_suppkey@1 as s_suppkey, s_name@2 as s_name,
s_address@3 as s_address, s_nationkey@4 as s_nationkey, n_nationkey@0 as
n_nationkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
s_nationkey@3)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@3], 16)
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([ps_partkey@0], 16)
+ StatsExec: rows=80000000
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([p_partkey@0], 16)
+ FilterExec: p_name@1 LIKE forest%, projection=[p_partkey@0]
+ StatsExec: rows=20000000
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([ps_partkey@0, ps_suppkey@1], 16)
+ SortMergeJoinExec: join_type=LeftSemi, on=[(ps_partkey@0, p_partkey@0)]
+ SortExec: expr=[ps_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_partkey@0], 16)
+ SortExec: expr=[p_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@0], 16)
+
+=== Stage 8 ===
+SortShuffleWriterExec: partitioning=Hash([l_partkey@0, l_suppkey@1], 16)
+ AggregateExec: mode=Partial, gby=[l_partkey@0 as l_partkey, l_suppkey@1 as
l_suppkey], aggr=[sum(lineitem.l_quantity)]
+ FilterExec: l_shipdate@3 >= 1994-01-01 AND l_shipdate@3 < 1995-01-01,
projection=[l_partkey@0, l_suppkey@1, l_quantity@2]
+ StatsExec: rows=600037902
+
+=== Stage 9 ===
+SortShuffleWriterExec: partitioning=Hash([ps_suppkey@0], 16)
+ ProjectionExec: expr=[ps_suppkey@1 as ps_suppkey]
+ SortMergeJoinExec: join_type=Inner, on=[(ps_partkey@0, l_partkey@1),
(ps_suppkey@1, l_suppkey@2)], filter=CAST(ps_availqty@0 AS Float64) >
Float64(0.5) * sum(lineitem.l_quantity)@1
+ SortExec: expr=[ps_partkey@0 ASC, ps_suppkey@1 ASC],
preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_partkey@0,
ps_suppkey@1], 16)
+ SortExec: expr=[l_partkey@1 ASC, l_suppkey@2 ASC],
preserve_partitioning=[true]
+ ProjectionExec: expr=[0.5 * CAST(sum(lineitem.l_quantity)@2 AS
Float64) as Float64(0.5) * sum(lineitem.l_quantity), l_partkey@0 as l_partkey,
l_suppkey@1 as l_suppkey]
+ AggregateExec: mode=FinalPartitioned, gby=[l_partkey@0 as l_partkey,
l_suppkey@1 as l_suppkey], aggr=[sum(lineitem.l_quantity)]
+ UnresolvedShuffleExec: partitioning: Hash([l_partkey@0,
l_suppkey@1], 16)
+
+=== Stage 10 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[s_name@0 ASC NULLS LAST], preserve_partitioning=[true]
+ ProjectionExec: expr=[s_name@1 as s_name, s_address@2 as s_address]
+ SortMergeJoinExec: join_type=LeftSemi, on=[(s_suppkey@0, ps_suppkey@0)]
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+ SortExec: expr=[ps_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_suppkey@0], 16)
+
+=== Stage 11 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [s_name@0 ASC NULLS LAST]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q21.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q21.txt
new file mode 100644
index 000000000..693152cd6
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q21.txt
@@ -0,0 +1,82 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ FilterExec: n_name@1 = SAUDI ARABIA, projection=[n_nationkey@0]
+ StatsExec: rows=25
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ StatsExec: rows=1000000
+
+=== Stage 4 ===
+SortShuffleWriterExec: partitioning=Hash([l_suppkey@1], 16)
+ FilterExec: l_receiptdate@3 > l_commitdate@2, projection=[l_orderkey@0,
l_suppkey@1]
+ StatsExec: rows=600037902
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@2], 16)
+ ProjectionExec: expr=[s_name@1 as s_name, s_nationkey@2 as s_nationkey,
l_orderkey@3 as l_orderkey, l_suppkey@4 as l_suppkey]
+ SortMergeJoinExec: join_type=Inner, on=[(s_suppkey@0, l_suppkey@1)]
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+ SortExec: expr=[l_suppkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_suppkey@1], 16)
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@0], 16)
+ FilterExec: o_orderstatus@1 = F, projection=[o_orderkey@0]
+ StatsExec: rows=150000000
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@1], 16)
+ ProjectionExec: expr=[s_name@0 as s_name, s_nationkey@1 as s_nationkey,
l_orderkey@2 as l_orderkey, l_suppkey@3 as l_suppkey]
+ SortMergeJoinExec: join_type=Inner, on=[(l_orderkey@2, o_orderkey@0)]
+ SortExec: expr=[l_orderkey@2 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@2], 16)
+ SortExec: expr=[o_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@0], 16)
+
+=== Stage 8 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@1], 16)
+ ProjectionExec: expr=[s_name@0 as s_name, l_orderkey@2 as l_orderkey,
l_suppkey@3 as l_suppkey]
+ ProjectionExec: expr=[s_name@1 as s_name, s_nationkey@2 as s_nationkey,
l_orderkey@3 as l_orderkey, l_suppkey@4 as l_suppkey, n_nationkey@0 as
n_nationkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
s_nationkey@1)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@1], 16)
+
+=== Stage 9 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ StatsExec: rows=600037902
+
+=== Stage 10 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ FilterExec: l_receiptdate@3 > l_commitdate@2, projection=[l_orderkey@0,
l_suppkey@1]
+ StatsExec: rows=600037902
+
+=== Stage 11 ===
+SortShuffleWriterExec: partitioning=Hash([s_name@0], 16)
+ AggregateExec: mode=Partial, gby=[s_name@0 as s_name], aggr=[count(Int64(1))]
+ ProjectionExec: expr=[s_name@0 as s_name]
+ SortMergeJoinExec: join_type=LeftAnti, on=[(l_orderkey@1,
l_orderkey@0)], filter=l_suppkey@1 != l_suppkey@0
+ SortMergeJoinExec: join_type=LeftSemi, on=[(l_orderkey@1,
l_orderkey@0)], filter=l_suppkey@1 != l_suppkey@0
+ SortExec: expr=[l_orderkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@1], 16)
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+
+=== Stage 12 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: TopK(fetch=100), expr=[numwait@1 DESC, s_name@0 ASC NULLS LAST],
preserve_partitioning=[true]
+ ProjectionExec: expr=[s_name@0 as s_name, count(Int64(1))@1 as numwait]
+ AggregateExec: mode=FinalPartitioned, gby=[s_name@0 as s_name],
aggr=[count(Int64(1))]
+ UnresolvedShuffleExec: partitioning: Hash([s_name@0], 16)
+
+=== Stage 13 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [numwait@1 DESC, s_name@0 ASC NULLS LAST], fetch=100
+ UnresolvedShuffleExec: partitioning: Hash([s_name@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q22.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q22.txt
new file mode 100644
index 000000000..4dd609587
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q22.txt
@@ -0,0 +1,35 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([c_custkey@0], 16)
+ FilterExec: substr(c_phone@1, 1, 2) IN (SET) ([13, 31, 23, 29, 30, 18, 17])
+ StatsExec: rows=15000000
+
+=== Stage 2 ===
+SortShuffleWriterExec: partitioning=Hash([o_custkey@0], 16)
+ StatsExec: rows=150000000
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([cntrycode@0], 16)
+ AggregateExec: mode=Partial, gby=[cntrycode@0 as cntrycode],
aggr=[count(Int64(1)), sum(custsale.c_acctbal)]
+ ProjectionExec: expr=[substr(c_phone@1, 1, 2) as cntrycode, c_acctbal@2 as
c_acctbal]
+ NestedLoopJoinExec: join_type=Inner, filter=join_proj_push_down_1@1 >
avg(customer.c_acctbal)@0, projection=[avg(customer.c_acctbal)@0, c_phone@1,
c_acctbal@2]
+ AggregateExec: mode=Single, gby=[], aggr=[avg(customer.c_acctbal)]
+ FilterExec: c_acctbal@1 > Some(0),15,2 AND substr(c_phone@0, 1, 2)
IN (SET) ([13, 31, 23, 29, 30, 18, 17]), projection=[c_acctbal@1]
+ StatsExec: rows=15000000
+ ProjectionExec: expr=[c_phone@1 as c_phone, c_acctbal@2 as c_acctbal,
CAST(c_acctbal@2 AS Decimal128(19, 6)) as join_proj_push_down_1]
+ SortMergeJoinExec: join_type=LeftAnti, on=[(c_custkey@0,
o_custkey@0)]
+ SortExec: expr=[c_custkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0], 16)
+ SortExec: expr=[o_custkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_custkey@0], 16)
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[cntrycode@0 ASC NULLS LAST], preserve_partitioning=[true]
+ ProjectionExec: expr=[cntrycode@0 as cntrycode, count(Int64(1))@1 as
numcust, sum(custsale.c_acctbal)@2 as totacctbal]
+ AggregateExec: mode=FinalPartitioned, gby=[cntrycode@0 as cntrycode],
aggr=[count(Int64(1)), sum(custsale.c_acctbal)]
+ UnresolvedShuffleExec: partitioning: Hash([cntrycode@0], 16)
+
+=== Stage 5 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [cntrycode@0 ASC NULLS LAST]
+ UnresolvedShuffleExec: partitioning: Hash([cntrycode@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q3.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q3.txt
new file mode 100644
index 000000000..a8d755ca9
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q3.txt
@@ -0,0 +1,40 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([c_custkey@0], 16)
+ FilterExec: c_mktsegment@1 = BUILDING, projection=[c_custkey@0]
+ StatsExec: rows=15000000
+
+=== Stage 2 ===
+SortShuffleWriterExec: partitioning=Hash([o_custkey@1], 16)
+ FilterExec: o_orderdate@2 < 1995-03-15
+ StatsExec: rows=150000000
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@0], 16)
+ ProjectionExec: expr=[o_orderkey@1 as o_orderkey, o_orderdate@3 as
o_orderdate, o_shippriority@4 as o_shippriority]
+ SortMergeJoinExec: join_type=Inner, on=[(c_custkey@0, o_custkey@1)]
+ SortExec: expr=[c_custkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0], 16)
+ SortExec: expr=[o_custkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_custkey@1], 16)
+
+=== Stage 4 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ FilterExec: l_shipdate@3 > 1995-03-15, projection=[l_orderkey@0,
l_extendedprice@1, l_discount@2]
+ StatsExec: rows=600037902
+
+=== Stage 5 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: TopK(fetch=10), expr=[revenue@1 DESC, o_orderdate@2 ASC NULLS
LAST], preserve_partitioning=[true]
+ ProjectionExec: expr=[l_orderkey@0 as l_orderkey,
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)@3 as revenue,
o_orderdate@1 as o_orderdate, o_shippriority@2 as o_shippriority]
+ AggregateExec: mode=SinglePartitioned, gby=[l_orderkey@2 as l_orderkey,
o_orderdate@0 as o_orderdate, o_shippriority@1 as o_shippriority],
aggr=[sum(lineitem.l_extendedprice * Some(1),20,0 - lineitem.l_discount) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)],
ordering_mode=PartiallySorted([0])
+ ProjectionExec: expr=[o_orderdate@1 as o_orderdate, o_shippriority@2
as o_shippriority, l_orderkey@3 as l_orderkey, l_extendedprice@4 as
l_extendedprice, l_discount@5 as l_discount]
+ SortMergeJoinExec: join_type=Inner, on=[(o_orderkey@0, l_orderkey@0)]
+ SortExec: expr=[o_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@0], 16)
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+
+=== Stage 6 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [revenue@1 DESC, o_orderdate@2 ASC NULLS LAST],
fetch=10
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q4.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q4.txt
new file mode 100644
index 000000000..bbb97d34b
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q4.txt
@@ -0,0 +1,31 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@0], 16)
+ FilterExec: o_orderdate@1 >= 1993-07-01 AND o_orderdate@1 < 1993-10-01,
projection=[o_orderkey@0, o_orderpriority@2]
+ StatsExec: rows=150000000
+
+=== Stage 2 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ FilterExec: l_receiptdate@2 > l_commitdate@1, projection=[l_orderkey@0]
+ StatsExec: rows=600037902
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderpriority@0], 16)
+ AggregateExec: mode=Partial, gby=[o_orderpriority@0 as o_orderpriority],
aggr=[count(Int64(1))]
+ ProjectionExec: expr=[o_orderpriority@1 as o_orderpriority]
+ SortMergeJoinExec: join_type=LeftSemi, on=[(o_orderkey@0, l_orderkey@0)]
+ SortExec: expr=[o_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@0], 16)
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[o_orderpriority@0 ASC NULLS LAST],
preserve_partitioning=[true]
+ ProjectionExec: expr=[o_orderpriority@0 as o_orderpriority,
count(Int64(1))@1 as order_count]
+ AggregateExec: mode=FinalPartitioned, gby=[o_orderpriority@0 as
o_orderpriority], aggr=[count(Int64(1))]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderpriority@0], 16)
+
+=== Stage 5 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [o_orderpriority@0 ASC NULLS LAST]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderpriority@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q5.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q5.txt
new file mode 100644
index 000000000..c159882e4
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q5.txt
@@ -0,0 +1,89 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([r_regionkey@0], 16)
+ FilterExec: r_name@1 = ASIA, projection=[r_regionkey@0]
+ StatsExec: rows=5
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([r_regionkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ StatsExec: rows=25
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([c_custkey@0], 16)
+ StatsExec: rows=15000000
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([o_custkey@1], 16)
+ FilterExec: o_orderdate@2 >= 1994-01-01 AND o_orderdate@2 < 1995-01-01,
projection=[o_orderkey@0, o_custkey@1]
+ StatsExec: rows=150000000
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@1], 16)
+ ProjectionExec: expr=[c_nationkey@1 as c_nationkey, o_orderkey@2 as
o_orderkey]
+ SortMergeJoinExec: join_type=Inner, on=[(c_custkey@0, o_custkey@1)]
+ SortExec: expr=[c_custkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0], 16)
+ SortExec: expr=[o_custkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_custkey@1], 16)
+
+=== Stage 8 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ StatsExec: rows=600037902
+
+=== Stage 9 ===
+SortShuffleWriterExec: partitioning=Hash([l_suppkey@1, c_nationkey@0], 16)
+ ProjectionExec: expr=[c_nationkey@0 as c_nationkey, l_suppkey@3 as
l_suppkey, l_extendedprice@4 as l_extendedprice, l_discount@5 as l_discount]
+ SortMergeJoinExec: join_type=Inner, on=[(o_orderkey@1, l_orderkey@0)]
+ SortExec: expr=[o_orderkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@1], 16)
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+
+=== Stage 10 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0, s_nationkey@1], 16)
+ StatsExec: rows=1000000
+
+=== Stage 11 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@2], 16)
+ ProjectionExec: expr=[l_extendedprice@2 as l_extendedprice, l_discount@3 as
l_discount, s_nationkey@5 as s_nationkey]
+ SortMergeJoinExec: join_type=Inner, on=[(l_suppkey@1, s_suppkey@0),
(c_nationkey@0, s_nationkey@1)]
+ SortExec: expr=[l_suppkey@1 ASC, c_nationkey@0 ASC],
preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_suppkey@1,
c_nationkey@0], 16)
+ SortExec: expr=[s_suppkey@0 ASC, s_nationkey@1 ASC],
preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0,
s_nationkey@1], 16)
+
+=== Stage 12 ===
+SortShuffleWriterExec: partitioning=Hash([n_regionkey@3], 16)
+ ProjectionExec: expr=[l_extendedprice@0 as l_extendedprice, l_discount@1 as
l_discount, n_name@4 as n_name, n_regionkey@5 as n_regionkey]
+ ProjectionExec: expr=[l_extendedprice@3 as l_extendedprice, l_discount@4
as l_discount, s_nationkey@5 as s_nationkey, n_nationkey@0 as n_nationkey,
n_name@1 as n_name, n_regionkey@2 as n_regionkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
s_nationkey@2)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@2], 16)
+
+=== Stage 13 ===
+SortShuffleWriterExec: partitioning=Hash([n_name@0], 16)
+ AggregateExec: mode=Partial, gby=[n_name@2 as n_name],
aggr=[sum(lineitem.l_extendedprice * Some(1),20,0 - lineitem.l_discount) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)]
+ ProjectionExec: expr=[l_extendedprice@0 as l_extendedprice, l_discount@1
as l_discount, n_name@2 as n_name]
+ ProjectionExec: expr=[l_extendedprice@1 as l_extendedprice, l_discount@2
as l_discount, n_name@3 as n_name, n_regionkey@4 as n_regionkey, r_regionkey@0
as r_regionkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(r_regionkey@0,
n_regionkey@3)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([n_regionkey@3], 16)
+
+=== Stage 14 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[revenue@1 DESC], preserve_partitioning=[true]
+ ProjectionExec: expr=[n_name@0 as n_name, sum(lineitem.l_extendedprice *
Int64(1) - lineitem.l_discount)@1 as revenue]
+ AggregateExec: mode=FinalPartitioned, gby=[n_name@0 as n_name],
aggr=[sum(lineitem.l_extendedprice * Some(1),20,0 - lineitem.l_discount) as
sum(lineitem.l_extendedprice * Int64(1) - lineitem.l_discount)]
+ UnresolvedShuffleExec: partitioning: Hash([n_name@0], 16)
+
+=== Stage 15 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [revenue@1 DESC]
+ UnresolvedShuffleExec: partitioning: Hash([n_name@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q6.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q6.txt
new file mode 100644
index 000000000..1da12a0cb
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q6.txt
@@ -0,0 +1,6 @@
+=== Stage 1 ===
+ShuffleWriterExec: partitioning: None
+ ProjectionExec: expr=[sum(lineitem.l_extendedprice * lineitem.l_discount)@0
as revenue]
+ AggregateExec: mode=Single, gby=[], aggr=[sum(lineitem.l_extendedprice *
lineitem.l_discount)]
+ FilterExec: l_shipdate@3 >= 1994-01-01 AND l_shipdate@3 < 1995-01-01 AND
l_discount@2 >= Some(5),15,2 AND l_discount@2 <= Some(7),15,2 AND l_quantity@0
< Some(2400),15,2, projection=[l_extendedprice@1, l_discount@2]
+ StatsExec: rows=600037902
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q7.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q7.txt
new file mode 100644
index 000000000..77cbc87a8
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q7.txt
@@ -0,0 +1,89 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ FilterExec: n_name@1 = FRANCE OR n_name@1 = GERMANY
+ StatsExec: rows=25
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ StatsExec: rows=1000000
+
+=== Stage 4 ===
+SortShuffleWriterExec: partitioning=Hash([l_suppkey@1], 16)
+ FilterExec: l_shipdate@4 >= 1995-01-01 AND l_shipdate@4 <= 1996-12-31
+ StatsExec: rows=600037902
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@1], 16)
+ ProjectionExec: expr=[s_nationkey@1 as s_nationkey, l_orderkey@2 as
l_orderkey, l_extendedprice@4 as l_extendedprice, l_discount@5 as l_discount,
l_shipdate@6 as l_shipdate]
+ SortMergeJoinExec: join_type=Inner, on=[(s_suppkey@0, l_suppkey@1)]
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+ SortExec: expr=[l_suppkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_suppkey@1], 16)
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@0], 16)
+ StatsExec: rows=150000000
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([o_custkey@4], 16)
+ ProjectionExec: expr=[s_nationkey@0 as s_nationkey, l_extendedprice@2 as
l_extendedprice, l_discount@3 as l_discount, l_shipdate@4 as l_shipdate,
o_custkey@6 as o_custkey]
+ SortMergeJoinExec: join_type=Inner, on=[(l_orderkey@1, o_orderkey@0)]
+ SortExec: expr=[l_orderkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@1], 16)
+ SortExec: expr=[o_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@0], 16)
+
+=== Stage 8 ===
+SortShuffleWriterExec: partitioning=Hash([c_custkey@0], 16)
+ StatsExec: rows=15000000
+
+=== Stage 9 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@0], 16)
+ ProjectionExec: expr=[s_nationkey@0 as s_nationkey, l_extendedprice@1 as
l_extendedprice, l_discount@2 as l_discount, l_shipdate@3 as l_shipdate,
c_nationkey@6 as c_nationkey]
+ SortMergeJoinExec: join_type=Inner, on=[(o_custkey@4, c_custkey@0)]
+ SortExec: expr=[o_custkey@4 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_custkey@4], 16)
+ SortExec: expr=[c_custkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0], 16)
+
+=== Stage 10 ===
+SortShuffleWriterExec: partitioning=Hash([c_nationkey@3], 16)
+ ProjectionExec: expr=[l_extendedprice@1 as l_extendedprice, l_discount@2 as
l_discount, l_shipdate@3 as l_shipdate, c_nationkey@4 as c_nationkey, n_name@6
as n_name]
+ ProjectionExec: expr=[s_nationkey@2 as s_nationkey, l_extendedprice@3 as
l_extendedprice, l_discount@4 as l_discount, l_shipdate@5 as l_shipdate,
c_nationkey@6 as c_nationkey, n_nationkey@0 as n_nationkey, n_name@1 as n_name]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
s_nationkey@0)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@0], 16)
+
+=== Stage 11 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([c_nationkey@3], 16)
+
+=== Stage 12 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ FilterExec: n_name@1 = GERMANY OR n_name@1 = FRANCE
+ StatsExec: rows=25
+
+=== Stage 13 ===
+SortShuffleWriterExec: partitioning=Hash([supp_nation@0, cust_nation@1,
l_year@2], 16)
+ AggregateExec: mode=Partial, gby=[supp_nation@0 as supp_nation,
cust_nation@1 as cust_nation, l_year@2 as l_year], aggr=[sum(shipping.volume)]
+ ProjectionExec: expr=[n_name@4 as supp_nation, n_name@6 as cust_nation,
date_part(YEAR, l_shipdate@2) as l_year, l_extendedprice@0 * (Some(1),20,0 -
l_discount@1) as volume]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(c_nationkey@3,
n_nationkey@0)], filter=n_name@0 = FRANCE AND n_name@1 = GERMANY OR n_name@0 =
GERMANY AND n_name@1 = FRANCE
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 14 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[supp_nation@0 ASC NULLS LAST, cust_nation@1 ASC NULLS LAST,
l_year@2 ASC NULLS LAST], preserve_partitioning=[true]
+ ProjectionExec: expr=[supp_nation@0 as supp_nation, cust_nation@1 as
cust_nation, l_year@2 as l_year, sum(shipping.volume)@3 as revenue]
+ AggregateExec: mode=FinalPartitioned, gby=[supp_nation@0 as supp_nation,
cust_nation@1 as cust_nation, l_year@2 as l_year], aggr=[sum(shipping.volume)]
+ UnresolvedShuffleExec: partitioning: Hash([supp_nation@0,
cust_nation@1, l_year@2], 16)
+
+=== Stage 15 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [supp_nation@0 ASC NULLS LAST, cust_nation@1 ASC
NULLS LAST, l_year@2 ASC NULLS LAST]
+ UnresolvedShuffleExec: partitioning: Hash([supp_nation@0, cust_nation@1,
l_year@2], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q8.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q8.txt
new file mode 100644
index 000000000..d70be863e
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q8.txt
@@ -0,0 +1,118 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([r_regionkey@0], 16)
+ FilterExec: r_name@1 = AMERICA, projection=[r_regionkey@0]
+ StatsExec: rows=5
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([r_regionkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ StatsExec: rows=25
+
+=== Stage 4 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([p_partkey@0], 16)
+ FilterExec: p_type@1 = ECONOMY ANODIZED STEEL, projection=[p_partkey@0]
+ StatsExec: rows=20000000
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([l_partkey@1], 16)
+ StatsExec: rows=600037902
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([l_suppkey@1], 16)
+ ProjectionExec: expr=[l_orderkey@1 as l_orderkey, l_suppkey@3 as l_suppkey,
l_extendedprice@4 as l_extendedprice, l_discount@5 as l_discount]
+ SortMergeJoinExec: join_type=Inner, on=[(p_partkey@0, l_partkey@1)]
+ SortExec: expr=[p_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@0], 16)
+ SortExec: expr=[l_partkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_partkey@1], 16)
+
+=== Stage 8 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ StatsExec: rows=1000000
+
+=== Stage 9 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ ProjectionExec: expr=[l_orderkey@0 as l_orderkey, l_extendedprice@2 as
l_extendedprice, l_discount@3 as l_discount, s_nationkey@5 as s_nationkey]
+ SortMergeJoinExec: join_type=Inner, on=[(l_suppkey@1, s_suppkey@0)]
+ SortExec: expr=[l_suppkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_suppkey@1], 16)
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+
+=== Stage 10 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@0], 16)
+ FilterExec: o_orderdate@2 >= 1995-01-01 AND o_orderdate@2 <= 1996-12-31
+ StatsExec: rows=150000000
+
+=== Stage 11 ===
+SortShuffleWriterExec: partitioning=Hash([o_custkey@3], 16)
+ ProjectionExec: expr=[l_extendedprice@1 as l_extendedprice, l_discount@2 as
l_discount, s_nationkey@3 as s_nationkey, o_custkey@5 as o_custkey,
o_orderdate@6 as o_orderdate]
+ SortMergeJoinExec: join_type=Inner, on=[(l_orderkey@0, o_orderkey@0)]
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+ SortExec: expr=[o_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@0], 16)
+
+=== Stage 12 ===
+SortShuffleWriterExec: partitioning=Hash([c_custkey@0], 16)
+ StatsExec: rows=15000000
+
+=== Stage 13 ===
+SortShuffleWriterExec: partitioning=Hash([c_nationkey@4], 16)
+ ProjectionExec: expr=[l_extendedprice@0 as l_extendedprice, l_discount@1 as
l_discount, s_nationkey@2 as s_nationkey, o_orderdate@4 as o_orderdate,
c_nationkey@6 as c_nationkey]
+ SortMergeJoinExec: join_type=Inner, on=[(o_custkey@3, c_custkey@0)]
+ SortExec: expr=[o_custkey@3 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_custkey@3], 16)
+ SortExec: expr=[c_custkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([c_custkey@0], 16)
+
+=== Stage 14 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@2], 16)
+ ProjectionExec: expr=[l_extendedprice@0 as l_extendedprice, l_discount@1 as
l_discount, s_nationkey@2 as s_nationkey, o_orderdate@3 as o_orderdate,
n_regionkey@6 as n_regionkey]
+ ProjectionExec: expr=[l_extendedprice@2 as l_extendedprice, l_discount@3
as l_discount, s_nationkey@4 as s_nationkey, o_orderdate@5 as o_orderdate,
c_nationkey@6 as c_nationkey, n_nationkey@0 as n_nationkey, n_regionkey@1 as
n_regionkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
c_nationkey@4)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([c_nationkey@4], 16)
+
+=== Stage 15 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@2], 16)
+
+=== Stage 16 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ StatsExec: rows=25
+
+=== Stage 17 ===
+SortShuffleWriterExec: partitioning=Hash([n_regionkey@3], 16)
+ ProjectionExec: expr=[l_extendedprice@0 as l_extendedprice, l_discount@1 as
l_discount, o_orderdate@3 as o_orderdate, n_regionkey@4 as n_regionkey,
n_name@6 as n_name]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(s_nationkey@2,
n_nationkey@0)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 18 ===
+SortShuffleWriterExec: partitioning=Hash([o_year@0], 16)
+ AggregateExec: mode=Partial, gby=[o_year@0 as o_year], aggr=[sum(CASE WHEN
all_nations.nation = BRAZIL THEN all_nations.volume ELSE Some(0),38,4 END) as
sum(CASE WHEN all_nations.nation = Utf8("BRAZIL") THEN all_nations.volume ELSE
Int64(0) END), sum(all_nations.volume)]
+ ProjectionExec: expr=[date_part(YEAR, o_orderdate@2) as o_year,
l_extendedprice@0 * (Some(1),20,0 - l_discount@1) as volume, n_name@4 as nation]
+ ProjectionExec: expr=[l_extendedprice@1 as l_extendedprice, l_discount@2
as l_discount, o_orderdate@3 as o_orderdate, n_regionkey@4 as n_regionkey,
n_name@5 as n_name, r_regionkey@0 as r_regionkey]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(r_regionkey@0,
n_regionkey@3)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([n_regionkey@3], 16)
+
+=== Stage 19 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[o_year@0 ASC NULLS LAST], preserve_partitioning=[true]
+ ProjectionExec: expr=[o_year@0 as o_year, sum(CASE WHEN all_nations.nation
= Utf8("BRAZIL") THEN all_nations.volume ELSE Int64(0) END)@1 /
sum(all_nations.volume)@2 as mkt_share]
+ AggregateExec: mode=FinalPartitioned, gby=[o_year@0 as o_year],
aggr=[sum(CASE WHEN all_nations.nation = BRAZIL THEN all_nations.volume ELSE
Some(0),38,4 END) as sum(CASE WHEN all_nations.nation = Utf8("BRAZIL") THEN
all_nations.volume ELSE Int64(0) END), sum(all_nations.volume)]
+ UnresolvedShuffleExec: partitioning: Hash([o_year@0], 16)
+
+=== Stage 20 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [o_year@0 ASC NULLS LAST]
+ UnresolvedShuffleExec: partitioning: Hash([o_year@0], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/approved/q9.txt
b/ballista/scheduler/tests/tpch_plan_stability/approved/q9.txt
new file mode 100644
index 000000000..3d91e1dd2
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/approved/q9.txt
@@ -0,0 +1,85 @@
+=== Stage 1 ===
+SortShuffleWriterExec: partitioning=Hash([n_nationkey@0], 16)
+ StatsExec: rows=25
+
+=== Stage 2 ===
+ShuffleWriterExec: partitioning: None
+ UnresolvedShuffleExec: partitioning: Hash([n_nationkey@0], 16)
+
+=== Stage 3 ===
+SortShuffleWriterExec: partitioning=Hash([p_partkey@0], 16)
+ FilterExec: p_name@1 LIKE %green%, projection=[p_partkey@0]
+ StatsExec: rows=20000000
+
+=== Stage 4 ===
+SortShuffleWriterExec: partitioning=Hash([l_partkey@1], 16)
+ StatsExec: rows=600037902
+
+=== Stage 5 ===
+SortShuffleWriterExec: partitioning=Hash([l_suppkey@2], 16)
+ ProjectionExec: expr=[l_orderkey@1 as l_orderkey, l_partkey@2 as l_partkey,
l_suppkey@3 as l_suppkey, l_quantity@4 as l_quantity, l_extendedprice@5 as
l_extendedprice, l_discount@6 as l_discount]
+ SortMergeJoinExec: join_type=Inner, on=[(p_partkey@0, l_partkey@1)]
+ SortExec: expr=[p_partkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([p_partkey@0], 16)
+ SortExec: expr=[l_partkey@1 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_partkey@1], 16)
+
+=== Stage 6 ===
+SortShuffleWriterExec: partitioning=Hash([s_suppkey@0], 16)
+ StatsExec: rows=1000000
+
+=== Stage 7 ===
+SortShuffleWriterExec: partitioning=Hash([l_suppkey@2, l_partkey@1], 16)
+ ProjectionExec: expr=[l_orderkey@0 as l_orderkey, l_partkey@1 as l_partkey,
l_suppkey@2 as l_suppkey, l_quantity@3 as l_quantity, l_extendedprice@4 as
l_extendedprice, l_discount@5 as l_discount, s_nationkey@7 as s_nationkey]
+ SortMergeJoinExec: join_type=Inner, on=[(l_suppkey@2, s_suppkey@0)]
+ SortExec: expr=[l_suppkey@2 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_suppkey@2], 16)
+ SortExec: expr=[s_suppkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([s_suppkey@0], 16)
+
+=== Stage 8 ===
+SortShuffleWriterExec: partitioning=Hash([ps_suppkey@1, ps_partkey@0], 16)
+ StatsExec: rows=80000000
+
+=== Stage 9 ===
+SortShuffleWriterExec: partitioning=Hash([l_orderkey@0], 16)
+ ProjectionExec: expr=[l_orderkey@0 as l_orderkey, l_quantity@3 as
l_quantity, l_extendedprice@4 as l_extendedprice, l_discount@5 as l_discount,
s_nationkey@6 as s_nationkey, ps_supplycost@9 as ps_supplycost]
+ SortMergeJoinExec: join_type=Inner, on=[(l_suppkey@2, ps_suppkey@1),
(l_partkey@1, ps_partkey@0)]
+ SortExec: expr=[l_suppkey@2 ASC, l_partkey@1 ASC],
preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_suppkey@2, l_partkey@1],
16)
+ SortExec: expr=[ps_suppkey@1 ASC, ps_partkey@0 ASC],
preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([ps_suppkey@1,
ps_partkey@0], 16)
+
+=== Stage 10 ===
+SortShuffleWriterExec: partitioning=Hash([o_orderkey@0], 16)
+ StatsExec: rows=150000000
+
+=== Stage 11 ===
+SortShuffleWriterExec: partitioning=Hash([s_nationkey@3], 16)
+ ProjectionExec: expr=[l_quantity@1 as l_quantity, l_extendedprice@2 as
l_extendedprice, l_discount@3 as l_discount, s_nationkey@4 as s_nationkey,
ps_supplycost@5 as ps_supplycost, o_orderdate@7 as o_orderdate]
+ SortMergeJoinExec: join_type=Inner, on=[(l_orderkey@0, o_orderkey@0)]
+ SortExec: expr=[l_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([l_orderkey@0], 16)
+ SortExec: expr=[o_orderkey@0 ASC], preserve_partitioning=[true]
+ UnresolvedShuffleExec: partitioning: Hash([o_orderkey@0], 16)
+
+=== Stage 12 ===
+SortShuffleWriterExec: partitioning=Hash([nation@0, o_year@1], 16)
+ AggregateExec: mode=Partial, gby=[nation@0 as nation, o_year@1 as o_year],
aggr=[sum(profit.amount)]
+ ProjectionExec: expr=[n_name@7 as nation, date_part(YEAR, o_orderdate@5)
as o_year, l_extendedprice@1 * (Some(1),20,0 - l_discount@2) - ps_supplycost@4
* l_quantity@0 as amount]
+ ProjectionExec: expr=[l_quantity@2 as l_quantity, l_extendedprice@3 as
l_extendedprice, l_discount@4 as l_discount, s_nationkey@5 as s_nationkey,
ps_supplycost@6 as ps_supplycost, o_orderdate@7 as o_orderdate, n_nationkey@0
as n_nationkey, n_name@1 as n_name]
+ HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(n_nationkey@0,
s_nationkey@3)]
+ UnresolvedShuffleExec: broadcast=true, upstream_partitions: 16
+ UnresolvedShuffleExec: partitioning: Hash([s_nationkey@3], 16)
+
+=== Stage 13 ===
+ShuffleWriterExec: partitioning: None
+ SortExec: expr=[nation@0 ASC NULLS LAST, o_year@1 DESC],
preserve_partitioning=[true]
+ ProjectionExec: expr=[nation@0 as nation, o_year@1 as o_year,
sum(profit.amount)@2 as sum_profit]
+ AggregateExec: mode=FinalPartitioned, gby=[nation@0 as nation, o_year@1
as o_year], aggr=[sum(profit.amount)]
+ UnresolvedShuffleExec: partitioning: Hash([nation@0, o_year@1], 16)
+
+=== Stage 14 ===
+ShuffleWriterExec: partitioning: None
+ SortPreservingMergeExec: [nation@0 ASC NULLS LAST, o_year@1 DESC]
+ UnresolvedShuffleExec: partitioning: Hash([nation@0, o_year@1], 16)
diff --git a/ballista/scheduler/tests/tpch_plan_stability/fixtures.rs
b/ballista/scheduler/tests/tpch_plan_stability/fixtures.rs
new file mode 100644
index 000000000..811370875
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/fixtures.rs
@@ -0,0 +1,229 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! TPC-H schema/fixture helpers and the distributed staged-plan text helper.
+//!
+//! `staged_plan_text` loads a TPC-H query's SQL, registers the 8 TPC-H tables
+//! (via [`TpchStatsTable`]) with SF100 row-count statistics, builds the
+//! physical plan with the static (Ballista) planner, breaks it into
+//! distributed query stages with [`DefaultDistributedPlanner`], and renders
+//! the stages to a normalized text representation suitable for snapshotting.
+
+use std::sync::Arc;
+
+use ballista_core::JobId;
+use ballista_core::extension::SessionConfigExt;
+use ballista_scheduler::planner::{DefaultDistributedPlanner,
DistributedPlanner};
+use datafusion::arrow::datatypes::{DataType, Field, Schema};
+use datafusion::physical_plan::display::DisplayableExecutionPlan;
+use datafusion::prelude::{SessionConfig, SessionContext};
+
+use crate::stats_table::TpchStatsTable;
+
+const TARGET_PARTITIONS: usize = 16;
+const JOB_ID: &str = "plan_stability";
+
+/// The 8 TPC-H table names and their SF100 row counts.
+pub const SF100_ROWS: &[(&str, usize)] = &[
+ ("region", 5),
+ ("nation", 25),
+ ("supplier", 1_000_000),
+ ("customer", 15_000_000),
+ ("part", 20_000_000),
+ ("partsupp", 80_000_000),
+ ("orders", 150_000_000),
+ ("lineitem", 600_037_902),
+];
+
+/// Returns the schema for a TPC-H table, copied verbatim from
+/// `benchmarks/src/bin/tpch.rs::get_schema` (that function lives in a binary
+/// and cannot be imported directly).
+pub fn tpch_schema(table: &str) -> Schema {
+ match table {
+ "part" => Schema::new(vec![
+ Field::new("p_partkey", DataType::Int64, false),
+ Field::new("p_name", DataType::Utf8, false),
+ Field::new("p_mfgr", DataType::Utf8, false),
+ Field::new("p_brand", DataType::Utf8, false),
+ Field::new("p_type", DataType::Utf8, false),
+ Field::new("p_size", DataType::Int32, false),
+ Field::new("p_container", DataType::Utf8, false),
+ Field::new("p_retailprice", DataType::Decimal128(15, 2), false),
+ Field::new("p_comment", DataType::Utf8, false),
+ ]),
+
+ "supplier" => Schema::new(vec![
+ Field::new("s_suppkey", DataType::Int64, false),
+ Field::new("s_name", DataType::Utf8, false),
+ Field::new("s_address", DataType::Utf8, false),
+ Field::new("s_nationkey", DataType::Int64, false),
+ Field::new("s_phone", DataType::Utf8, false),
+ Field::new("s_acctbal", DataType::Decimal128(15, 2), false),
+ Field::new("s_comment", DataType::Utf8, false),
+ ]),
+
+ "partsupp" => Schema::new(vec![
+ Field::new("ps_partkey", DataType::Int64, false),
+ Field::new("ps_suppkey", DataType::Int64, false),
+ Field::new("ps_availqty", DataType::Int32, false),
+ Field::new("ps_supplycost", DataType::Decimal128(15, 2), false),
+ Field::new("ps_comment", DataType::Utf8, false),
+ ]),
+
+ "customer" => Schema::new(vec![
+ Field::new("c_custkey", DataType::Int64, false),
+ Field::new("c_name", DataType::Utf8, false),
+ Field::new("c_address", DataType::Utf8, false),
+ Field::new("c_nationkey", DataType::Int64, false),
+ Field::new("c_phone", DataType::Utf8, false),
+ Field::new("c_acctbal", DataType::Decimal128(15, 2), false),
+ Field::new("c_mktsegment", DataType::Utf8, false),
+ Field::new("c_comment", DataType::Utf8, false),
+ ]),
+
+ "orders" => Schema::new(vec![
+ Field::new("o_orderkey", DataType::Int64, false),
+ Field::new("o_custkey", DataType::Int64, false),
+ Field::new("o_orderstatus", DataType::Utf8, false),
+ Field::new("o_totalprice", DataType::Decimal128(15, 2), false),
+ Field::new("o_orderdate", DataType::Date32, false),
+ Field::new("o_orderpriority", DataType::Utf8, false),
+ Field::new("o_clerk", DataType::Utf8, false),
+ Field::new("o_shippriority", DataType::Int32, false),
+ Field::new("o_comment", DataType::Utf8, false),
+ ]),
+
+ "lineitem" => Schema::new(vec![
+ Field::new("l_orderkey", DataType::Int64, false),
+ Field::new("l_partkey", DataType::Int64, false),
+ Field::new("l_suppkey", DataType::Int64, false),
+ Field::new("l_linenumber", DataType::Int32, false),
+ Field::new("l_quantity", DataType::Decimal128(15, 2), false),
+ Field::new("l_extendedprice", DataType::Decimal128(15, 2), false),
+ Field::new("l_discount", DataType::Decimal128(15, 2), false),
+ Field::new("l_tax", DataType::Decimal128(15, 2), false),
+ Field::new("l_returnflag", DataType::Utf8, false),
+ Field::new("l_linestatus", DataType::Utf8, false),
+ Field::new("l_shipdate", DataType::Date32, false),
+ Field::new("l_commitdate", DataType::Date32, false),
+ Field::new("l_receiptdate", DataType::Date32, false),
+ Field::new("l_shipinstruct", DataType::Utf8, false),
+ Field::new("l_shipmode", DataType::Utf8, false),
+ Field::new("l_comment", DataType::Utf8, false),
+ ]),
+
+ "nation" => Schema::new(vec![
+ Field::new("n_nationkey", DataType::Int64, false),
+ Field::new("n_name", DataType::Utf8, false),
+ Field::new("n_regionkey", DataType::Int64, false),
+ Field::new("n_comment", DataType::Utf8, false),
+ ]),
+
+ "region" => Schema::new(vec![
+ Field::new("r_regionkey", DataType::Int64, false),
+ Field::new("r_name", DataType::Utf8, false),
+ Field::new("r_comment", DataType::Utf8, false),
+ ]),
+
+ other => panic!("unknown tpch table {other}"),
+ }
+}
+
+fn make_ctx() -> SessionContext {
+ let config =
+
SessionConfig::new_with_ballista().with_target_partitions(TARGET_PARTITIONS);
+ let ctx = SessionContext::new_with_config(config);
+ for (table, rows) in SF100_ROWS {
+ let schema = Arc::new(tpch_schema(table));
+ ctx.register_table(*table, Arc::new(TpchStatsTable::new(schema,
*rows)))
+ .unwrap();
+ }
+ ctx
+}
+
+fn is_query_stmt(stmt: &str) -> bool {
+ let u = stmt.trim_start().to_uppercase();
+ u.starts_with("SELECT") || u.starts_with("WITH")
+}
+
+/// Produce the normalized distributed staged-plan text for a TPC-H query.
+pub async fn staged_plan_text(query_name: &str) -> String {
+ // Read the query SQL directly from the canonical benchmark location rather
+ // than a copy, so a change to a benchmark query surfaces as a golden diff.
+ let sql_path = format!(
+ "{}/../../benchmarks/queries/{query_name}.sql",
+ env!("CARGO_MANIFEST_DIR")
+ );
+ let sql = std::fs::read_to_string(&sql_path)
+ .unwrap_or_else(|e| panic!("read {sql_path}: {e}"));
+
+ let ctx = make_ctx();
+
+ // Split into statements; execute DDL, capture the physical plan of the
answer
+ // (last SELECT/WITH) statement. Single-statement queries take the one
statement.
+ let stmts: Vec<&str> = sql
+ .split(';')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .collect();
+ let answer_idx = stmts
+ .iter()
+ .rposition(|s| is_query_stmt(s))
+ .expect("no SELECT/WITH statement in query");
+
+ let mut physical = None;
+ for (i, stmt) in stmts.iter().enumerate() {
+ if i == answer_idx {
+ physical = Some(
+ ctx.sql(stmt)
+ .await
+ .unwrap()
+ .create_physical_plan()
+ .await
+ .unwrap(),
+ );
+ } else {
+ // DDL such as CREATE VIEW / DROP VIEW (q15) — apply it.
+ ctx.sql(stmt).await.unwrap().collect().await.unwrap();
+ }
+ }
+ let physical = physical.unwrap();
+
+ let mut planner = DefaultDistributedPlanner::new();
+ let state = ctx.state();
+ let job_id: JobId = JOB_ID.into();
+ let stages = planner
+ .plan_query_stages(&job_id, physical, state.config().options())
+ .unwrap();
+
+ let mut out = String::new();
+ for stage in &stages {
+ out.push_str(&format!("=== Stage {} ===\n", stage.stage_id()));
+ let ep: &dyn datafusion::physical_plan::ExecutionPlan = stage.as_ref();
+
out.push_str(&DisplayableExecutionPlan::new(ep).indent(false).to_string());
+ out.push('\n');
+ }
+ normalize(&out)
+}
+
+fn normalize(plan: &str) -> String {
+ // Strip the fixed job id and any hex addresses so output is byte-stable.
+ let s = plan.replace(JOB_ID, "<job_id>");
+ // remove 0x… addresses if any appear
+ let re = regex::Regex::new(r"0x[0-9a-fA-F]+").unwrap();
+ re.replace_all(&s, "0x<addr>").into_owned()
+}
diff --git a/ballista/scheduler/tests/tpch_plan_stability/main.rs
b/ballista/scheduler/tests/tpch_plan_stability/main.rs
new file mode 100644
index 000000000..38d6d5f2a
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/main.rs
@@ -0,0 +1,112 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod fixtures;
+mod stats_table;
+
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use datafusion::arrow::datatypes::{DataType, Field, Schema};
+use datafusion::common::stats::Precision;
+use datafusion::prelude::SessionContext;
+
+use stats_table::TpchStatsTable;
+
+#[tokio::test]
+async fn stats_table_reports_injected_rows() {
+ let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int64,
false)]));
+ let ctx = SessionContext::new();
+ ctx.register_table(
+ "t",
+ Arc::new(TpchStatsTable::new(Arc::clone(&schema), 12_345)),
+ )
+ .unwrap();
+
+ let plan = ctx
+ .sql("SELECT a FROM t")
+ .await
+ .unwrap()
+ .create_physical_plan()
+ .await
+ .unwrap();
+
+ let stats = plan.partition_statistics(None).unwrap();
+ assert_eq!(stats.num_rows, Precision::Inexact(12_345));
+}
+
+#[tokio::test]
+async fn staged_plan_text_is_nonempty_and_shuffled() {
+ let text = fixtures::staged_plan_text("q1").await;
+ assert!(
+ text.contains("ShuffleWriterExec"),
+ "q1 plan should contain shuffle stages:\n{text}"
+ );
+ assert!(
+ !text.contains("plan_stability"),
+ "job id should be normalized out"
+ );
+ assert!(text.contains("=== Stage"), "stage banners present");
+}
+
+#[tokio::test]
+async fn multi_statement_q15_plans() {
+ let text = fixtures::staged_plan_text("q15").await;
+ assert!(
+ text.contains("=== Stage"),
+ "q15 (create/select/drop view) should plan:\n{text}"
+ );
+}
+
+fn golden_path(name: &str) -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("tests/tpch_plan_stability/approved")
+ .join(format!("{name}.txt"))
+}
+
+async fn check_query(name: &str) {
+ let actual = fixtures::staged_plan_text(name).await;
+ let path = golden_path(name);
+ if std::env::var("BALLISTA_GENERATE_GOLDEN").is_ok() {
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(&path, actual.trim_end().to_string() + "\n").unwrap();
+ return;
+ }
+ let expected = std::fs::read_to_string(&path).unwrap_or_else(|_| {
+ panic!("missing golden {path:?}; regenerate with
BALLISTA_GENERATE_GOLDEN=1")
+ });
+ assert_eq!(
+ actual.trim_end(),
+ expected.trim_end(),
+ "distributed plan drift for {name}. Review the change; if intended,
regenerate with \
+ BALLISTA_GENERATE_GOLDEN=1 cargo test -p ballista-scheduler --test
tpch_plan_stability"
+ );
+}
+
+macro_rules! plan_stability_test {
+ ($($name:ident),+ $(,)?) => {
+ $(
+ #[tokio::test]
+ async fn $name() { check_query(stringify!($name)).await; }
+ )+
+ };
+}
+
+plan_stability_test!(
+ q1, q2, q3, q4, q5, q6, q7, q8, q9, q10, q11, q12, q13, q14, q15, q16,
q17, q18, q19,
+ q20, q21, q22
+);
diff --git a/ballista/scheduler/tests/tpch_plan_stability/stats_table.rs
b/ballista/scheduler/tests/tpch_plan_stability/stats_table.rs
new file mode 100644
index 000000000..2d8d7b1b5
--- /dev/null
+++ b/ballista/scheduler/tests/tpch_plan_stability/stats_table.rs
@@ -0,0 +1,152 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt;
+use std::sync::Arc;
+
+use datafusion::arrow::datatypes::SchemaRef;
+use datafusion::catalog::{Session, TableProvider};
+use datafusion::common::stats::Precision;
+use datafusion::common::{Result, Statistics};
+use datafusion::execution::TaskContext;
+use datafusion::logical_expr::{Expr, TableType};
+use datafusion::physical_expr::EquivalenceProperties;
+use datafusion::physical_plan::execution_plan::{
+ Boundedness, EmissionType, SchedulingType,
+};
+use datafusion::physical_plan::memory::MemoryStream;
+use datafusion::physical_plan::{
+ DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties,
+ SendableRecordBatchStream,
+};
+
+use async_trait::async_trait;
+
+/// A leaf execution plan that produces no rows but reports a fixed row-count
+/// statistic, so the physical optimizer makes realistic join/broadcast choices
+/// without any data on disk.
+#[derive(Debug)]
+pub struct StatsExec {
+ schema: SchemaRef,
+ num_rows: usize,
+ cache: Arc<PlanProperties>,
+}
+
+impl StatsExec {
+ pub fn new(schema: SchemaRef, num_rows: usize) -> Self {
+ let cache = PlanProperties::new(
+ EquivalenceProperties::new(Arc::clone(&schema)),
+ Partitioning::UnknownPartitioning(1),
+ EmissionType::Incremental,
+ Boundedness::Bounded,
+ )
+ .with_scheduling_type(SchedulingType::Cooperative);
+ Self {
+ schema,
+ num_rows,
+ cache: Arc::new(cache),
+ }
+ }
+}
+
+impl DisplayAs for StatsExec {
+ fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter) ->
fmt::Result {
+ match t {
+ DisplayFormatType::Default | DisplayFormatType::Verbose => {
+ write!(f, "StatsExec: rows={}", self.num_rows)
+ }
+ DisplayFormatType::TreeRender => write!(f, ""),
+ }
+ }
+}
+
+impl ExecutionPlan for StatsExec {
+ fn name(&self) -> &'static str {
+ "StatsExec"
+ }
+ fn properties(&self) -> &Arc<PlanProperties> {
+ &self.cache
+ }
+ fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+ vec![]
+ }
+ fn with_new_children(
+ self: Arc<Self>,
+ _: Vec<Arc<dyn ExecutionPlan>>,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ Ok(self)
+ }
+ fn execute(
+ &self,
+ _partition: usize,
+ _context: Arc<TaskContext>,
+ ) -> Result<SendableRecordBatchStream> {
+ Ok(Box::pin(MemoryStream::try_new(
+ vec![],
+ Arc::clone(&self.schema),
+ None,
+ )?))
+ }
+ fn partition_statistics(&self, _partition: Option<usize>) ->
Result<Arc<Statistics>> {
+ Ok(Arc::new(
+ Statistics::new_unknown(&self.schema)
+ .with_num_rows(Precision::Inexact(self.num_rows)),
+ ))
+ }
+}
+
+/// A dataless `TableProvider` with a real schema and a fixed row-count
statistic.
+#[derive(Debug)]
+pub struct TpchStatsTable {
+ schema: SchemaRef,
+ num_rows: usize,
+}
+
+impl TpchStatsTable {
+ pub fn new(schema: SchemaRef, num_rows: usize) -> Self {
+ Self { schema, num_rows }
+ }
+}
+
+#[async_trait]
+impl TableProvider for TpchStatsTable {
+ fn schema(&self) -> SchemaRef {
+ Arc::clone(&self.schema)
+ }
+ fn table_type(&self) -> TableType {
+ TableType::Base
+ }
+ async fn scan(
+ &self,
+ _state: &dyn Session,
+ projection: Option<&Vec<usize>>,
+ _filters: &[Expr],
+ _limit: Option<usize>,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ let schema = match projection {
+ Some(p) => Arc::new(self.schema.project(p)?),
+ None => Arc::clone(&self.schema),
+ };
+ Ok(Arc::new(StatsExec::new(schema, self.num_rows)))
+ }
+ fn statistics(&self) -> Option<Statistics> {
+ Some(
+ Statistics::new_unknown(&self.schema)
+ .with_num_rows(Precision::Inexact(self.num_rows)),
+ )
+ }
+}
diff --git a/dev/release/rat_exclude_files.txt
b/dev/release/rat_exclude_files.txt
index 47a5ee2eb..83d47821c 100644
--- a/dev/release/rat_exclude_files.txt
+++ b/dev/release/rat_exclude_files.txt
@@ -34,6 +34,7 @@ requirements.txt
.gitattributes
benchmarks/queries/q*.sql
ballista/scheduler/testdata/*
+ballista/scheduler/tests/tpch_plan_stability/approved/*
**/yarn.lock
python/requirements*.txt
**/testdata/*
diff --git a/dev/update-tpch-plan-stability.sh
b/dev/update-tpch-plan-stability.sh
new file mode 100755
index 000000000..072e2a1b6
--- /dev/null
+++ b/dev/update-tpch-plan-stability.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# Regenerate the approved TPC-H distributed plans for the plan-stability suite.
+# Run after an intended planner/plan-shape change, then review the diff.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+BALLISTA_GENERATE_GOLDEN=1 cargo test -p ballista-scheduler --test
tpch_plan_stability
+echo "Regenerated. Review changes under
ballista/scheduler/tests/tpch_plan_stability/approved/"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]