postgres.git / summary / log / commit / refs
commit f836b688f8dc627ce97760dec5569aa7c064ffe9
Author: Richard Guo <rguo@postgresql.org>
Date: Mon Sep 07 02:50:56 2026 +0000
Fix duplicate qual clauses in parameterized paths
When outer-join identity 3 permits a join to commute with lower outer
joins, we generate multiple clone versions of its join clause, of
which only one should be applied in any given plan. When building a
join relation's restriction list, subbuild_joinrel_restrictlist
selects the appropriate clone by checking required_relids and
incompatible_relids, but no such selection was made for movable join
clauses pushed down into a parameterized path. As a result, a
parameterized scan or join could enforce more than one clone of the
same condition, shown by EXPLAIN as a duplicate qual. This wastes
effort evaluating the same condition repeatedly. What is worse, it
applies the clause's selectivity multiple times, underestimating the
result's row count.
To fix, in get_baserel_parampathinfo and get_joinrel_parampathinfo,
skip a clone clause if any outer join listed in its
incompatible_relids has already been computed below the point of
evaluation.
Back-patch to v19, but no further. While this bug goes back to v16,
applying the fix in released branches risks destabilizing plans that
users are currently happy with, and there have been no field
complaints.
Author: Richard Guo <guofenglinux@gmail.com>
Reviewed-by: Tom Lane <tgl@sss.pgh.pa.us>
Reviewed-by: Tender Wang <tndrwang@gmail.com>
Discussion: https://postgr.es/m/CAMbWs4-g5TFF5k=jfe3JVWqtv_cCTOMmQRUp0_E8fApgqcqBng@mail.gmail.com
Backpatch-through: 19
src/backend/optimizer/util/relnode.c | 14 ++++++++++
src/include/nodes/pathnodes.h | 2 +-
src/test/regress/expected/join.out | 51 +++++++++++++++++++++++++++++++++---
src/test/regress/sql/join.sql | 22 ++++++++++++++++
4 files changed, 85 insertions(+), 4 deletions(-)
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index ee69f81945f..8862004cbad 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -1760,6 +1760,15 @@ get_baserel_parampathinfo(PlannerInfo *root, RelOptInfo *baserel,
{
RestrictInfo *rinfo = (RestrictInfo *) lfirst(lc);
+ /*
+ * A clone clause must not be enforced here if an outer join it is
+ * incompatible with has already been computed below the point of
+ * evaluation; some other clone is the right one to apply.
+ */
+ if ((rinfo->has_clone || rinfo->is_clone) &&
+ bms_overlap(rinfo->incompatible_relids, joinrelids))
+ continue;
+
if (join_clause_is_movable_into(rinfo,
baserel->relids,
joinrelids))
@@ -1892,6 +1901,11 @@ get_joinrel_parampathinfo(PlannerInfo *root, RelOptInfo *joinrel,
{
RestrictInfo *rinfo = (RestrictInfo *) lfirst(lc);
+ /* As above, reject clones incompatible with a computed outer join */
+ if ((rinfo->has_clone || rinfo->is_clone) &&
+ bms_overlap(rinfo->incompatible_relids, join_and_req))
+ continue;
+
if (join_clause_is_movable_into(rinfo,
joinrel->relids,
join_and_req) &&
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index c48e656ce80..460c4f4d8dc 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2823,7 +2823,7 @@ typedef struct LimitPath
* clause and null Vars that it uses. In practice we only bother to populate
* it for "clone" clauses, as it's currently only needed to prevent multiple
* clones of the same clause from being accepted for evaluation at the same
- * join level.
+ * plan level, whether at a join or within a parameterized path.
*
* There is also an outer_relids field, which is NULL except for outer join
* clauses; for those, it is the set of relids on the outer side of the
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 6a85bb246ef..cee3a7b1f22 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -6664,8 +6664,8 @@ select count(*) from int4_tbl t1 left join
left join lateral
(select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
on t2.bx = t3.cnt;
- QUERY PLAN
--------------------------------------------------------------------------
+ QUERY PLAN
+------------------------------------------------------------
Aggregate
-> Nested Loop Left Join
-> Seq Scan on int4_tbl t1
@@ -6677,7 +6677,7 @@ select count(*) from int4_tbl t1 left join
-> Hash
-> Seq Scan on int8_tbl b
-> Subquery Scan on t3
- Filter: ((b.q1 = t3.cnt) AND (b.q1 = t3.cnt))
+ Filter: (b.q1 = t3.cnt)
-> Seq Scan on int4_tbl c
Filter: (f1 = (1))
(14 rows)
@@ -6693,6 +6693,51 @@ select count(*) from int4_tbl t1 left join
25
(1 row)
+--
+-- check that a cloned outer-join qual is not enforced multiple times when
+-- it is moved into a parameterized join
+--
+explain (costs off)
+select count(*) from int4_tbl t1 left join
+ (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2
+ on true
+ left join
+ (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
+ join int4_tbl t4 on t3.cnt = t4.f1)
+ on t2.bx = t3.cnt + t4.f1;
+ QUERY PLAN
+----------------------------------------------------------------
+ Aggregate
+ -> Nested Loop Left Join
+ -> Seq Scan on int4_tbl t1
+ -> Materialize
+ -> Nested Loop Left Join
+ -> Hash Left Join
+ Hash Cond: (a.f1 = b.q2)
+ -> Seq Scan on int4_tbl a
+ -> Hash
+ -> Seq Scan on int8_tbl b
+ -> Hash Join
+ Hash Cond: (t4.f1 = c.f1)
+ Join Filter: (b.q1 = (c.f1 + t4.f1))
+ -> Seq Scan on int4_tbl t4
+ -> Hash
+ -> Seq Scan on int4_tbl c
+ Filter: (f1 = (1))
+(17 rows)
+
+select count(*) from int4_tbl t1 left join
+ (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2
+ on true
+ left join
+ (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
+ join int4_tbl t4 on t3.cnt = t4.f1)
+ on t2.bx = t3.cnt + t4.f1;
+ count
+-------
+ 25
+(1 row)
+
--
-- test successful handling of full join underneath left join (bug #14105)
--
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index c541321e6a2..b353073f21e 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -2402,6 +2402,28 @@ select count(*) from int4_tbl t1 left join
(select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
on t2.bx = t3.cnt;
+--
+-- check that a cloned outer-join qual is not enforced multiple times when
+-- it is moved into a parameterized join
+--
+
+explain (costs off)
+select count(*) from int4_tbl t1 left join
+ (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2
+ on true
+ left join
+ (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
+ join int4_tbl t4 on t3.cnt = t4.f1)
+ on t2.bx = t3.cnt + t4.f1;
+
+select count(*) from int4_tbl t1 left join
+ (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2
+ on true
+ left join
+ (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
+ join int4_tbl t4 on t3.cnt = t4.f1)
+ on t2.bx = t3.cnt + t4.f1;
+
--
-- test successful handling of full join underneath left join (bug #14105)
--