From 6f238f3d5aa7b82e67c6b2c2a0eb49c3799cf47d Mon Sep 17 00:00:00 2001
From: Richard Guo <guofenglinux@gmail.com>
Date: Mon, 17 Aug 2026 10:01:36 +0900
Subject: [PATCH v1] Fix CPU cost of right-semi and right-anti hash joins

final_cost_hashjoin() charges cpu_tuple_cost, a per-returned-row cost,
on hashjointuples, which counts outer-side tuples.  JOIN_RIGHT_SEMI
and JOIN_RIGHT_ANTI emit inner rows instead so this overestimates
their CPU cost by roughly the ratio of the outer side to the inner
one.  Those jointypes exist to hash the smaller input and scan the
larger one, so the overestimate is largest in just the cases where
they are the right choice.  The planner then falls back on a plan that
runs the larger input through a hash table.

Charge cpu_tuple_cost on the path's own row estimate, which for these
jointypes is the number of inner rows emitted.  The qpquals are still
evaluated once per tuple that gets through the hashjoin proper, so
those stay on hashjointuples.

Nestloop and mergejoin need no equivalent fix: neither supports
JOIN_RIGHT_SEMI, nestloop doesn't support JOIN_RIGHT_ANTI either, and
final_cost_mergejoin() takes its count from approx_tuple_count(),
which multiplies the two input sizes together and so gives the same
answer whichever side is on the outside.
---
 src/backend/optimizer/path/costsize.c         | 25 ++++++---
 src/test/regress/expected/join.out            | 54 +++++++++++++++++++
 src/test/regress/expected/select_parallel.out | 34 ++++++------
 src/test/regress/sql/join.sql                 | 32 +++++++++++
 4 files changed, 121 insertions(+), 24 deletions(-)

diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index fd794c946ab..09e87af79d6 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -4460,10 +4460,10 @@ final_cost_hashjoin(PlannerInfo *root, HashPath *path,
 	Cost		run_cost = workspace->run_cost;
 	int			numbuckets = workspace->numbuckets;
 	int			numbatches = workspace->numbatches;
-	Cost		cpu_per_tuple;
 	QualCost	hash_qual_cost;
 	QualCost	qp_qual_cost;
 	double		hashjointuples;
+	double		hashjoinrows;
 	double		virtualbuckets;
 	Selectivity innerbucketsize;
 	Selectivity innermcvfreq;
@@ -4681,14 +4681,25 @@ final_cost_hashjoin(PlannerInfo *root, HashPath *path,
 	}
 
 	/*
-	 * For each tuple that gets through the hashjoin proper, we charge
-	 * cpu_tuple_cost plus the cost of evaluating additional restriction
-	 * clauses that are to be applied at the join.  (This is pessimistic since
-	 * not all of the quals may get evaluated at each tuple.)
+	 * For each tuple that gets through the hashjoin proper, we charge the
+	 * cost of evaluating additional restriction clauses that are to be
+	 * applied at the join.  (This is pessimistic since not all of the quals
+	 * may get evaluated at each tuple.)  cpu_tuple_cost, on the other hand,
+	 * is charged once per row the join emits.
+	 *
+	 * Those two counts coincide except for JOIN_RIGHT_SEMI and
+	 * JOIN_RIGHT_ANTI, which emit inner rows whereas hashjointuples counts
+	 * outer-side tuples.  For those, use the path's own row estimate as the
+	 * number of rows emitted.
 	 */
 	startup_cost += qp_qual_cost.startup;
-	cpu_per_tuple = cpu_tuple_cost + qp_qual_cost.per_tuple;
-	run_cost += cpu_per_tuple * hashjointuples;
+	if (path->jpath.jointype == JOIN_RIGHT_SEMI ||
+		path->jpath.jointype == JOIN_RIGHT_ANTI)
+		hashjoinrows = path->jpath.path.rows;
+	else
+		hashjoinrows = hashjointuples;
+	run_cost += qp_qual_cost.per_tuple * hashjointuples +
+		cpu_tuple_cost * hashjoinrows;
 
 	/* tlist eval costs are paid per output row, not per tuple scanned */
 	startup_cost += path->jpath.path.pathtarget->cost.startup;
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 05f359d3aa7..426461a6fb9 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -3106,6 +3106,60 @@ and t1.fivethous < 5;
                ->  Parallel Seq Scan on tenk1 t2
 (8 rows)
 
+rollback;
+--
+-- test that hash right semi/anti joins aren't charged for the outer side's
+-- row count, which they don't emit
+--
+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;
 --
 -- regression test for bug #13908 (hash join with skew tuples & nbatch increase)
diff --git a/src/test/regress/expected/select_parallel.out b/src/test/regress/expected/select_parallel.out
index 933921d1860..e1344215644 100644
--- a/src/test/regress/expected/select_parallel.out
+++ b/src/test/regress/expected/select_parallel.out
@@ -1125,27 +1125,27 @@ reset role;
 explain (costs off, verbose)
   select count(*) from tenk1 a where (unique1, two) in
     (select unique1, row_number() over() from tenk1 b);
-                                       QUERY PLAN                                       
-----------------------------------------------------------------------------------------
+                                          QUERY PLAN                                          
+----------------------------------------------------------------------------------------------
  Aggregate
    Output: count(*)
-   ->  Hash Right Semi Join
-         Hash Cond: ((b.unique1 = a.unique1) AND ((row_number() OVER w1) = a.two))
-         ->  WindowAgg
-               Output: b.unique1, row_number() OVER w1
-               Window: w1 AS (ROWS UNBOUNDED PRECEDING)
-               ->  Gather
-                     Output: b.unique1
-                     Workers Planned: 4
-                     ->  Parallel Index Only Scan using tenk1_unique1 on public.tenk1 b
-                           Output: b.unique1
-         ->  Hash
+   ->  Hash Semi Join
+         Hash Cond: ((a.unique1 = b.unique1) AND (a.two = (row_number() OVER w1)))
+         ->  Gather
                Output: a.unique1, a.two
-               ->  Gather
+               Workers Planned: 4
+               ->  Parallel Seq Scan on public.tenk1 a
                      Output: a.unique1, a.two
-                     Workers Planned: 4
-                     ->  Parallel Seq Scan on public.tenk1 a
-                           Output: a.unique1, a.two
+         ->  Hash
+               Output: b.unique1, (row_number() OVER w1)
+               ->  WindowAgg
+                     Output: b.unique1, row_number() OVER w1
+                     Window: w1 AS (ROWS UNBOUNDED PRECEDING)
+                     ->  Gather
+                           Output: b.unique1
+                           Workers Planned: 4
+                           ->  Parallel Index Only Scan using tenk1_unique1 on public.tenk1 b
+                                 Output: b.unique1
 (19 rows)
 
 -- LIMIT/OFFSET within sub-selects can't be pushed to workers.
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index 450bd5bbf2c..fe86d79dfbb 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -779,6 +779,38 @@ and t1.fivethous < 5;
 
 rollback;
 
+--
+-- test that hash right semi/anti joins aren't charged for the outer side's
+-- row count, which they don't emit
+--
+
+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;
+
 --
 -- regression test for bug #13908 (hash join with skew tuples & nbatch increase)
 --
-- 
2.37.1 (Apple Git-137.1)

