From c1189e46f6dc6dea2fc8695d7824ae9ee1c875a7 Mon Sep 17 00:00:00 2001
From: Haibo Yan <tristan.yim@gmail.com>
Date: Tue, 18 Aug 2026 23:48:41 -0700
Subject: [PATCH] Fix candidate-count estimate for right-semi/right-anti hash
 joins

JOIN_RIGHT_SEMI and JOIN_RIGHT_ANTI hash join paths physically swap
the two semi/anti join inputs relative to the canonical orientation:
the relation that is logically the semijoin/antijoin's outer (LHS) is
hashed and probed by what is logically the inner (RHS).  This lets the
planner hash whichever side is smaller instead of always hashing the
logical inner.

compute_semi_anti_join_factors() and the semifactors it produces
(outer_match_frac, match_count) describe the canonical, un-swapped
semi/anti orientation: they are computed once per joinrel from the
sjinfo-based canonical outer and inner relations, independently of
which of the two sibling hash join paths (ordinary or "right") is
being costed.

final_cost_hashjoin()'s SEMI/ANTI/inner_unique costing branch combines
that fraction with outer_path_rows to estimate hashjointuples, the
number of hash-clause candidate pairs the executor will examine.  For
an ordinary-orientation path this is correct, because outer_path_rows
there is the same canonical outer relation semifactors was computed
for.  But for a physically swapped right-semi/right-anti path,
outer_path_rows belongs to the opposite relation: the estimate ends up
multiplying one relation's row count by a match fraction that was
computed for a different relation's join behavior.

The resulting hashjointuples estimate can be wrong by orders of
magnitude.  In one reproducer, a right semi join between a 100-row
unique-keyed table and a 2,000,000-row table produces an estimate of
2,000,000 hash-clause candidates versus roughly 400 actually examined
at execution time.  Because that inflated estimate feeds directly into
cpu_tuple_cost's contribution to the join's cost, it makes an
otherwise attractive right-semi/right-anti plan (hash the small side,
scan the large one) look far more expensive than the alternative of
hashing the large side instead, causing the planner to avoid it.

approx_tuple_count() already estimates this same population -- the
number of hash-clause candidate pairs -- for the non-SEMI/ANTI branch
of the same function, via a formula (selectivity times the product of
the two input row counts) that is symmetric under exchanging which of
the two relations is called "outer": it does not depend on which side
of the join is physically probed.  Use it for JOIN_RIGHT_SEMI and
JOIN_RIGHT_ANTI too, leaving the existing CPU cost formula (cpu_tuple_cost
plus qp_qual_cost.per_tuple, both charged on hashjointuples) unchanged.
This fixes the candidate-count estimate at its source, rather than
changing what population the existing cost terms are applied to.

Add regression tests covering both JOIN_RIGHT_SEMI and JOIN_RIGHT_ANTI
with a unique-keyed build side, including a sparse case with a much
larger outer/probe relation where only a small fraction of its rows
can hash-match -- the regime where the old estimate's error was
largest -- covering both jointypes on the same pair of tables.
---
 src/backend/optimizer/path/costsize.c | 15 ++++-
 src/test/regress/expected/join.out    | 96 +++++++++++++++++++++++++++
 src/test/regress/sql/join.sql         | 62 +++++++++++++++++
 3 files changed, 171 insertions(+), 2 deletions(-)

diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index fd794c946ab..fe2d00cd76f 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -4650,8 +4650,19 @@ final_cost_hashjoin(PlannerInfo *root, HashPath *path,
 			(outer_path_rows - outer_matched_rows) *
 			clamp_row_est(inner_path_rows / virtualbuckets) * 0.05;
 
-		/* Get # of tuples that will pass the basic join */
-		if (path->jpath.jointype == JOIN_ANTI)
+		/*
+		 * Get # of tuples that will pass the basic join.  For
+		 * JOIN_RIGHT_SEMI/JOIN_RIGHT_ANTI, the physical outer and inner
+		 * paths are swapped, while the semi/anti factors above describe
+		 * the canonical orientation.  Applying outer_match_frac to
+		 * outer_path_rows would therefore mix estimates for different
+		 * relations.  Use the symmetric join-pair estimate from
+		 * approx_tuple_count() instead.
+		 */
+		if (path->jpath.jointype == JOIN_RIGHT_SEMI ||
+			path->jpath.jointype == JOIN_RIGHT_ANTI)
+			hashjointuples = approx_tuple_count(root, &path->jpath, hashclauses);
+		else if (path->jpath.jointype == JOIN_ANTI)
 			hashjointuples = outer_path_rows - outer_matched_rows;
 		else
 			hashjointuples = outer_matched_rows;
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 05f359d3aa7..19994f269b6 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -3106,6 +3106,102 @@ and t1.fivethous < 5;
                ->  Parallel Seq Scan on tenk1 t2
 (8 rows)
 
+rollback;
+--
+-- test candidate-count costing for hash right semi/anti joins when the
+-- physical outer path is swapped relative to the canonical semi/anti join
+--
+begin;
+create temp table hj_small(id int primary key);
+create temp table hj_large(v int);
+insert into hj_small select i from generate_series(1,200)i;
+insert into hj_large select (i % 500) + 11 from generate_series(1,1000)i;
+analyze hj_small, hj_large;
+-- ensure we hash the small side and scan the large one, not the reverse
+explain (costs off)
+select count(*) from hj_small s where exists
+  (select 1 from hj_large r where r.v = s.id);
+                QUERY PLAN                
+------------------------------------------
+ Aggregate
+   ->  Hash Right Semi Join
+         Hash Cond: (r.v = s.id)
+         ->  Seq Scan on hj_large r
+         ->  Hash
+               ->  Seq Scan on hj_small s
+(6 rows)
+
+-- and check we get the expected results
+select count(*) from hj_small s where exists
+  (select 1 from hj_large r where r.v = s.id);
+ count 
+-------
+   190
+(1 row)
+
+-- likewise for a right anti join
+explain (costs off)
+select count(*) from hj_small s where not exists
+  (select 1 from hj_large r where r.v = s.id);
+                QUERY PLAN                
+------------------------------------------
+ Aggregate
+   ->  Hash Right Anti Join
+         Hash Cond: (r.v = s.id)
+         ->  Seq Scan on hj_large r
+         ->  Hash
+               ->  Seq Scan on hj_small s
+(6 rows)
+
+select count(*) from hj_small s where not exists
+  (select 1 from hj_large r where r.v = s.id);
+ count 
+-------
+    10
+(1 row)
+
+rollback;
+--
+-- same, but with a large outer/probe side and a small unique-keyed build
+-- side where only a small fraction of the outer rows can possibly
+-- hash-match: this exercises the inner_unique costing branch with the
+-- widest orientation mismatch between outer_path_rows and the canonical
+-- outer_match_frac, i.e. where the old candidate-count estimate was
+-- wrong by the largest margin
+--
+begin;
+create temp table hj_sparse_small(id int primary key, tag int);
+create temp table hj_sparse_large(v int);
+insert into hj_sparse_small select g, g from generate_series(1, 50) g;
+insert into hj_sparse_large select (g % 50000) + 1 from generate_series(1, 100000) g;
+analyze hj_sparse_small, hj_sparse_large;
+set max_parallel_workers_per_gather = 0;
+set work_mem = '4MB';
+explain (costs off)
+select hj_sparse_small.tag from hj_sparse_small where exists
+  (select 1 from hj_sparse_large where hj_sparse_large.v = hj_sparse_small.id);
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Hash Right Semi Join
+   Hash Cond: (hj_sparse_large.v = hj_sparse_small.id)
+   ->  Seq Scan on hj_sparse_large
+   ->  Hash
+         ->  Seq Scan on hj_sparse_small
+(5 rows)
+
+-- likewise for a right anti join, reusing the same tables
+explain (costs off)
+select hj_sparse_small.tag from hj_sparse_small where not exists
+  (select 1 from hj_sparse_large where hj_sparse_large.v = hj_sparse_small.id);
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Hash Right Anti Join
+   Hash Cond: (hj_sparse_large.v = hj_sparse_small.id)
+   ->  Seq Scan on hj_sparse_large
+   ->  Hash
+         ->  Seq Scan on hj_sparse_small
+(5 rows)
+
 rollback;
 --
 -- regression test for bug #13908 (hash join with skew tuples & nbatch increase)
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index 450bd5bbf2c..c6d990ec0ac 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -779,6 +779,68 @@ and t1.fivethous < 5;
 
 rollback;
 
+--
+-- test candidate-count costing for hash right semi/anti joins when the
+-- physical outer path is swapped relative to the canonical semi/anti join
+--
+
+begin;
+
+create temp table hj_small(id int primary key);
+create temp table hj_large(v int);
+insert into hj_small select i from generate_series(1,200)i;
+insert into hj_large select (i % 500) + 11 from generate_series(1,1000)i;
+analyze hj_small, hj_large;
+
+-- ensure we hash the small side and scan the large one, not the reverse
+explain (costs off)
+select count(*) from hj_small s where exists
+  (select 1 from hj_large r where r.v = s.id);
+
+-- and check we get the expected results
+select count(*) from hj_small s where exists
+  (select 1 from hj_large r where r.v = s.id);
+
+-- likewise for a right anti join
+explain (costs off)
+select count(*) from hj_small s where not exists
+  (select 1 from hj_large r where r.v = s.id);
+
+select count(*) from hj_small s where not exists
+  (select 1 from hj_large r where r.v = s.id);
+
+rollback;
+
+--
+-- same, but with a large outer/probe side and a small unique-keyed build
+-- side where only a small fraction of the outer rows can possibly
+-- hash-match: this exercises the inner_unique costing branch with the
+-- widest orientation mismatch between outer_path_rows and the canonical
+-- outer_match_frac, i.e. where the old candidate-count estimate was
+-- wrong by the largest margin
+--
+
+begin;
+
+create temp table hj_sparse_small(id int primary key, tag int);
+create temp table hj_sparse_large(v int);
+insert into hj_sparse_small select g, g from generate_series(1, 50) g;
+insert into hj_sparse_large select (g % 50000) + 1 from generate_series(1, 100000) g;
+analyze hj_sparse_small, hj_sparse_large;
+set max_parallel_workers_per_gather = 0;
+set work_mem = '4MB';
+
+explain (costs off)
+select hj_sparse_small.tag from hj_sparse_small where exists
+  (select 1 from hj_sparse_large where hj_sparse_large.v = hj_sparse_small.id);
+
+-- likewise for a right anti join, reusing the same tables
+explain (costs off)
+select hj_sparse_small.tag from hj_sparse_small where not exists
+  (select 1 from hj_sparse_large where hj_sparse_large.v = hj_sparse_small.id);
+
+rollback;
+
 --
 -- regression test for bug #13908 (hash join with skew tuples & nbatch increase)
 --
-- 
2.54.0

