On Wed, Sep 9, 2026 at 4:16 PM Robert Haas <[email protected]> wrote:
> Generally, all of these problems stem from advice
> enforcement (which tries to make the plan obey the advice) being out
> of step with advice feedback (which says whether the plan actually did
> obey the advice). As far as I have found so far, those things are in
> lock step for all of the cases that test_plan_advice exercises, or to
> say it differently, they're in lock step for all the kinds of advice
> that pg_plan_advice generates itself.
AI found a related case and prepared the attached patch:
pg_plan_advice generates Gather advice for a set-operation upper relation
that it cannot enforce. This is on REL_19_STABLE at b368bdd2.
Reproducer:
load 'pg_plan_advice';
create table t (a int);
set max_parallel_workers_per_gather = 1;
set min_parallel_table_scan_size = 0;
set parallel_setup_cost = 0;
set parallel_tuple_cost = 0;
set enable_gathermerge = off;
explain (costs off, plan_advice)
select a from t union select a from t;
This produces a Gather above the Parallel Append and generates:
GATHER((unnamed_subquery unnamed_subquery#2))
Feeding that advice back already reports it as only partially matched:
set pg_plan_advice.advice =
'GATHER((unnamed_subquery unnamed_subquery#2))';
set parallel_setup_cost = 1000000;
explain (costs off)
select a from t union select a from t;
The second plan has no Gather, despite the supplied advice, and reports:
GATHER((unnamed_subquery unnamed_subquery#2)) /* partially matched */
The Gather belongs to the set-operation upper relation. That relation is
built by plan_set_operations(), where pg_plan_advice has no hook to enforce
the generated advice. This is also consistent with the documented
limitation that set-operation planning cannot currently be controlled.
The attached patch records which PlannerInfo objects contain set
operations, maps their RTIs into the final flattened range table, and
omits GATHER and GATHER_MERGE advice for those upper relations. It retains
Gather advice within set-operation input queries and for flattened union
all append relations.
I have not manually reviewed the C changes. The following passed:
make check
make -C src/test/isolation check
make -C contrib/pg_plan_advice check
make -C src/test/modules/test_plan_advice check
The pg_plan_advice regression additions cover union at the top level and
in a subquery, Gather within intersect inputs, and flattened union all.
--
Nik
From f782a7e198171cf62220e1d221f7b74e09475d2a Mon Sep 17 00:00:00 2001
From: Nik Samokhvalov <[email protected]>
Date: Mon, 21 Sep 2026 07:18:41 -0700
Subject: [PATCH] Don't emit Gather advice for set-operation upper rels
---
contrib/pg_plan_advice/expected/gather.out | 65 ++++++++++++++++++++++
contrib/pg_plan_advice/pgpa_planner.c | 1 +
contrib/pg_plan_advice/pgpa_planner.h | 3 +
contrib/pg_plan_advice/pgpa_walker.c | 40 ++++++++++++-
contrib/pg_plan_advice/sql/gather.sql | 14 +++++
5 files changed, 122 insertions(+), 1 deletion(-)
diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
index 0cc0dedf8..fbb915d22 100644
--- a/contrib/pg_plan_advice/expected/gather.out
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -166,6 +166,71 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE)
(17 rows)
COMMIT;
+-- Gather over a set-operation upper relation cannot be controlled by advice.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT id FROM gt_dim UNION SELECT id FROM gt_dim;
+ QUERY PLAN
+--------------------------------------------------------
+ HashAggregate
+ Group Key: gt_dim.id
+ -> Gather
+ Workers Planned: 1
+ -> Parallel Append
+ -> Parallel Seq Scan on gt_dim
+ -> Parallel Seq Scan on gt_dim gt_dim_1
+ Generated Plan Advice:
+ SEQ_SCAN(gt_dim@setop_1 gt_dim@setop_2)
+(9 rows)
+
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM (SELECT id FROM gt_dim UNION SELECT id FROM gt_dim) s;
+ QUERY PLAN
+--------------------------------------------------------
+ HashAggregate
+ Group Key: gt_dim.id
+ -> Gather
+ Workers Planned: 1
+ -> Parallel Append
+ -> Parallel Seq Scan on gt_dim
+ -> Parallel Seq Scan on gt_dim gt_dim_1
+ Generated Plan Advice:
+ SEQ_SCAN(gt_dim@setop_1 gt_dim@setop_2)
+ NO_GATHER(s)
+(10 rows)
+
+-- Gather within the input queries remains controllable.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT id FROM gt_dim INTERSECT SELECT id FROM gt_dim;
+ QUERY PLAN
+--------------------------------------------------
+ HashSetOp Intersect
+ -> Gather
+ Workers Planned: 1
+ -> Parallel Seq Scan on gt_dim
+ -> Gather
+ Workers Planned: 1
+ -> Parallel Seq Scan on gt_dim gt_dim_1
+ Generated Plan Advice:
+ SEQ_SCAN(gt_dim@setop_1 gt_dim@setop_2)
+ GATHER(gt_dim@setop_1 gt_dim@setop_2)
+ NO_GATHER(unnamed_subquery unnamed_subquery#2)
+(11 rows)
+
+-- A flattened UNION ALL is planned as an append relation, not an upper relation.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT id FROM gt_dim UNION ALL SELECT id FROM gt_dim;
+ QUERY PLAN
+--------------------------------------------------
+ Gather
+ Workers Planned: 1
+ -> Parallel Append
+ -> Parallel Seq Scan on gt_dim
+ -> Parallel Seq Scan on gt_dim gt_dim_1
+ Generated Plan Advice:
+ SEQ_SCAN(gt_dim gt_dim#2)
+ GATHER(unnamed_subquery)
+(8 rows)
+
-- Force a Gather or Gather Merge on one relation but no parallelism on other.
BEGIN;
SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';
diff --git a/contrib/pg_plan_advice/pgpa_planner.c b/contrib/pg_plan_advice/pgpa_planner.c
index c57df4aa7..30ebbe74d 100644
--- a/contrib/pg_plan_advice/pgpa_planner.c
+++ b/contrib/pg_plan_advice/pgpa_planner.c
@@ -2016,6 +2016,7 @@ pgpa_planner_get_proot(pgpa_planner_state *pps, PlannerInfo *root)
/* Set plan name and alternative plan name. */
new_proot->plan_name = root->plan_name;
new_proot->alternative_plan_name = root->alternative_plan_name;
+ new_proot->has_set_operations = root->parse->setOperations != NULL;
/*
* If the newly-created proot shares an alternative_plan_name with one or
diff --git a/contrib/pg_plan_advice/pgpa_planner.h b/contrib/pg_plan_advice/pgpa_planner.h
index 366142a0c..fe94e38ef 100644
--- a/contrib/pg_plan_advice/pgpa_planner.h
+++ b/contrib/pg_plan_advice/pgpa_planner.h
@@ -52,6 +52,9 @@ typedef struct pgpa_planner_info
bool has_rtoffset;
Index rtoffset;
+ /* Does this query level contain a set operation? */
+ bool has_set_operations;
+
/*
* List of Bitmapset objects. Each represents the relid set of a relation
* that the planner considers making unique during semijoin planning.
diff --git a/contrib/pg_plan_advice/pgpa_walker.c b/contrib/pg_plan_advice/pgpa_walker.c
index 7cb227fb1..d46be2470 100644
--- a/contrib/pg_plan_advice/pgpa_walker.c
+++ b/contrib/pg_plan_advice/pgpa_walker.c
@@ -82,6 +82,7 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
ListCell *lc;
List *sj_unique_rtis = NULL;
List *sj_nonunique_qfs = NULL;
+ List *setop_relid_sets = NIL;
List *chosen_proots;
List *discarded_proots;
@@ -101,9 +102,24 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
pgpa_walk_recursively(walker, plan, false, NULL, NIL, false);
}
- /* Adjust RTIs from sj_unique_rels for the flattened range table. */
+ /* Collect query-level RTIs using the final, flattened range table. */
foreach_ptr(pgpa_planner_info, proot, proots)
{
+ if (proot->has_set_operations && proot->has_rtoffset)
+ {
+ Bitmapset *relids = NULL;
+
+ for (int rti = 1; rti <= proot->rid_array_size; ++rti)
+ {
+ if (proot->rid_array[rti - 1].alias_name != NULL)
+ relids = bms_add_member(relids,
+ rti + proot->rtoffset);
+ }
+
+ if (relids != NULL)
+ setop_relid_sets = lappend(setop_relid_sets, relids);
+ }
+
/* If there are no sj_unique_rels for this proot, we can skip it. */
if (proot->sj_unique_rels == NIL)
continue;
@@ -168,6 +184,10 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
* (Should the Partial Aggregates in such a case be created in an
* UPPERREL_GROUP_AGG with a non-empty relid set? Right now that doesn't
* happen, but it seems like it would make life easier for us if it did.)
+ *
+ * Likewise, omit Gather advice for a set-operation upper relation, which
+ * cannot enforce such advice. Gather nodes within a set-operation input
+ * query use different RTIs and remain controllable.
*/
for (int t = 0; t < NUM_PGPA_QF_TYPES; ++t)
{
@@ -175,6 +195,24 @@ pgpa_plan_walker(pgpa_plan_walker_context *walker, PlannedStmt *pstmt,
foreach_ptr(pgpa_query_feature, qf, walker->query_features[t])
{
+ bool over_setop = false;
+
+ if ((t == PGPAQF_GATHER || t == PGPAQF_GATHER_MERGE) &&
+ qf->relids != NULL)
+ {
+ foreach_node(Bitmapset, setop_relids, setop_relid_sets)
+ {
+ if (bms_is_subset(qf->relids, setop_relids))
+ {
+ over_setop = true;
+ break;
+ }
+ }
+ }
+
+ if (over_setop)
+ continue;
+
if (qf->relids != NULL)
query_features = lappend(query_features, qf);
else
diff --git a/contrib/pg_plan_advice/sql/gather.sql b/contrib/pg_plan_advice/sql/gather.sql
index 776666bf1..2008b465f 100644
--- a/contrib/pg_plan_advice/sql/gather.sql
+++ b/contrib/pg_plan_advice/sql/gather.sql
@@ -45,6 +45,20 @@ EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
COMMIT;
+-- Gather over a set-operation upper relation cannot be controlled by advice.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT id FROM gt_dim UNION SELECT id FROM gt_dim;
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT * FROM (SELECT id FROM gt_dim UNION SELECT id FROM gt_dim) s;
+
+-- Gather within the input queries remains controllable.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT id FROM gt_dim INTERSECT SELECT id FROM gt_dim;
+
+-- A flattened UNION ALL is planned as an append relation, not an upper relation.
+EXPLAIN (COSTS OFF, PLAN_ADVICE)
+ SELECT id FROM gt_dim UNION ALL SELECT id FROM gt_dim;
+
-- Force a Gather or Gather Merge on one relation but no parallelism on other.
BEGIN;
SET LOCAL pg_plan_advice.advice = 'gather_merge(f) no_gather(d)';