From 1b13aeda31ccefa881ec91bb796dafc06157e852 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Sun, 19 Jul 2026 18:11:38 +0200
Subject: [PATCH v10 02/21] Check expected filters even for partial paths

Partial paths can have expected filters, as long as the filter is
fully produced in the worker.

Fix handling of filters in add_partial_path

We're allowing partial paths with expected filters, in the hope that the
filter will be realized by a join in the parallel worker (we can't pass
it through Gather yet).

But add_partial_path is expected to sort paths by cost, so that various
places can simply do linitial(rel->partial_pathlist) to get the cheapest
path. For example generate_gather_paths() does that. But those places
don't expect paths with filters, so we must not break the order.

Sort paths by number of filters (and then number of disabled nodes, and
finally cost), so that the first path always has no filters. We know
there's such path, because the filtered paths are in addition to that.
---
 contrib/pg_plan_advice/expected/gather.out |  21 +-
 src/backend/optimizer/path/joinpath.c      | 218 ++++++++++++++++-----
 src/backend/optimizer/util/pathnode.c      |  27 ++-
 3 files changed, 198 insertions(+), 68 deletions(-)

diff --git a/contrib/pg_plan_advice/expected/gather.out b/contrib/pg_plan_advice/expected/gather.out
index 6486d7c449d..ea6ebefef74 100644
--- a/contrib/pg_plan_advice/expected/gather.out
+++ b/contrib/pg_plan_advice/expected/gather.out
@@ -18,24 +18,23 @@ VACUUM ANALYZE gt_fact;
 -- By default, we expect Gather Merge with a parallel hash join.
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
 	SELECT * FROM gt_fact f JOIN gt_dim d ON f.dim_id = d.id ORDER BY d.id;
-                    QUERY PLAN                    
---------------------------------------------------
+                      QUERY PLAN                       
+-------------------------------------------------------
  Gather Merge
    Workers Planned: 1
-   ->  Merge Join
-         Merge Cond: (f.dim_id = d.id)
-         ->  Sort
-               Sort Key: f.dim_id
+   ->  Sort
+         Sort Key: f.dim_id
+         ->  Parallel Hash Join
+               Hash Cond: (f.dim_id = d.id)
                ->  Parallel Seq Scan on gt_fact f
-         ->  Sort
-               Sort Key: d.id
-               ->  Seq Scan on gt_dim d
+               ->  Parallel Hash
+                     ->  Parallel Seq Scan on gt_dim d
  Generated Plan Advice:
    JOIN_ORDER(f d)
-   MERGE_JOIN_PLAIN(d)
+   HASH_JOIN(d)
    SEQ_SCAN(f d)
    GATHER_MERGE((f d))
-(15 rows)
+(14 rows)
 
 -- Force Gather or Gather Merge of both relations together.
 BEGIN;
diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c
index 10dfeeac719..79f54b935fc 100644
--- a/src/backend/optimizer/path/joinpath.c
+++ b/src/backend/optimizer/path/joinpath.c
@@ -1378,23 +1378,68 @@ try_partial_nestloop_path(PlannerInfo *root,
 	 */
 	initial_cost_nestloop(root, &workspace, jointype, nestloop_subtype,
 						  outer_path, inner_path, extra);
-	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
-								   workspace.startup_cost,
-								   workspace.total_cost, pathkeys))
-		return;
 
-	/* Might be good enough to be worth trying, so let's try it. */
-	add_partial_path(joinrel, (Path *)
-					 create_nestloop_path(root,
-										  joinrel,
-										  jointype,
-										  &workspace,
-										  extra,
-										  outer_path,
-										  inner_path,
-										  extra->restrictlist,
-										  pathkeys,
-										  NULL));
+	/*
+	 * Account for expected Bloom filters carried by the input paths.  A
+	 * nestloop never builds a Bloom filter, so if it is the source of any
+	 * expected filter the path is contradicted and must be rejected;
+	 * otherwise the filters propagate to the resulting path.
+	 */
+	{
+		bool		contradicted;
+		List	   *jfilters;
+
+		jfilters = compute_join_expected_filters(root, outer_path, inner_path,
+												 jointype, false, NIL,
+												 &contradicted, NULL);
+
+		/*
+		 * Contradicted means the inner/outer paths expect this join to
+		 * realize one of the expected filters, but a nestloop can't do that.
+		 * So these input paths are incompatible with a nestloop.
+		 */
+		if (contradicted)
+		{
+			return;
+		}
+
+		/*
+		 * If the path expects any filters, it's excluded from the cost
+		 * pruning performed by add_path (so don't bother with
+		 * add_path_precheck either). Once a path has all filters satisfied
+		 * (or there were no filters), do the pruning as usual.
+		 *
+		 * XXX We don't want the "regular" paths without filters to get
+		 * removed, because we need the option to pick from join algorithms.
+		 * Paths with filters would likely win (simply because there are fewer
+		 * rows), but they only work with hashjoins. However, maybe the
+		 * hashjoin won't work for some reason (e.g. it wouldn't fit into
+		 * work_mem).
+		 *
+		 * XXX Maybe it'd be cleaner to do this in add_path_precheck (i.e.
+		 * make it return true for paths with expected filters).
+		 */
+		if (jfilters != NIL ||
+			add_partial_path_precheck(joinrel, workspace.disabled_nodes,
+									  workspace.startup_cost,
+									  workspace.total_cost, pathkeys))
+		{
+			Path	   *nlpath;
+
+			nlpath = (Path *) create_nestloop_path(root,
+												   joinrel,
+												   jointype,
+												   &workspace,
+												   extra,
+												   outer_path,
+												   inner_path,
+												   extra->restrictlist,
+												   pathkeys,
+												   NULL);
+			set_join_path_expected_filters(nlpath, jfilters);
+			add_partial_path(joinrel, nlpath);
+		}
+	}
 }
 
 /*
@@ -1596,27 +1641,53 @@ try_partial_mergejoin_path(PlannerInfo *root,
 						   outer_presorted_keys,
 						   extra);
 
-	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
-								   workspace.startup_cost,
-								   workspace.total_cost, pathkeys))
-		return;
+	/*
+	 * Account for expected Bloom filters carried by the input paths.  A
+	 * mergejoin never builds a Bloom filter, so it contradicts (and cannot
+	 * use) any input path for which it would be the filter's source.
+	 * Filter-bearing paths bypass the precheck, since their reduced cost
+	 * isn't comparable to ordinary paths.
+	 *
+	 * XXX see the comments in try_nestloop_path
+	 */
+	{
+		bool		contradicted;
+		List	   *jfilters;
 
-	/* Might be good enough to be worth trying, so let's try it. */
-	add_partial_path(joinrel, (Path *)
-					 create_mergejoin_path(root,
-										   joinrel,
-										   jointype,
-										   &workspace,
-										   extra,
-										   outer_path,
-										   inner_path,
-										   extra->restrictlist,
-										   pathkeys,
-										   NULL,
-										   mergeclauses,
-										   outersortkeys,
-										   innersortkeys,
-										   outer_presorted_keys));
+		jfilters = compute_join_expected_filters(root, outer_path, inner_path,
+												 jointype, false, NIL,
+												 &contradicted, NULL);
+		if (contradicted)
+		{
+			return;
+		}
+
+		if (jfilters != NIL ||
+			add_partial_path_precheck(joinrel, workspace.disabled_nodes,
+									  workspace.startup_cost,
+									  workspace.total_cost, pathkeys))
+		{
+			Path	   *mjpath;
+
+			/* Might be good enough to be worth trying, so let's try it. */
+			mjpath = (Path *) create_mergejoin_path(root,
+													joinrel,
+													jointype,
+													&workspace,
+													extra,
+													outer_path,
+													inner_path,
+													extra->restrictlist,
+													pathkeys,
+													NULL,
+													mergeclauses,
+													outersortkeys,
+													innersortkeys,
+													outer_presorted_keys);
+			set_join_path_expected_filters(mjpath, jfilters);
+			add_partial_path(joinrel, mjpath);
+		}
+	}
 }
 
 /*
@@ -1765,24 +1836,65 @@ try_partial_hashjoin_path(PlannerInfo *root,
 	 */
 	initial_cost_hashjoin(root, &workspace, jointype, hashclauses,
 						  outer_path, inner_path, extra, parallel_hash);
-	if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes,
-								   workspace.startup_cost,
-								   workspace.total_cost, NIL))
-		return;
 
-	/* Might be good enough to be worth trying, so let's try it. */
-	add_partial_path(joinrel, (Path *)
-					 create_hashjoin_path(root,
-										  joinrel,
-										  jointype,
-										  &workspace,
-										  extra,
-										  outer_path,
-										  inner_path,
-										  parallel_hash,
-										  extra->restrictlist,
-										  NULL,
-										  hashclauses));
+	/*
+	 * Account for expected Bloom filters carried by the input paths.  A hash
+	 * join builds and pushes down a Bloom filter, so it realizes (and removes
+	 * from propagation) any expected filter for which it is the source; other
+	 * filters propagate upward.  Filter-bearing paths bypass the precheck.
+	 */
+	{
+		bool		contradicted;
+		List	   *jfilters;
+		List	   *realized = NIL;
+
+		/*
+		 * A parallel-aware hash join can't build a pushed-down Bloom filter
+		 */
+		bool		can_realize = !parallel_hash;
+
+		jfilters = compute_join_expected_filters(root, outer_path, inner_path,
+												 jointype, can_realize,
+												 can_realize ? hashclauses : NIL,
+												 &contradicted,
+												 can_realize ? &realized : NULL);
+
+		/* XXX Can a hashjoin contradict a filter? Probably not. */
+		if (contradicted)
+		{
+			return;
+		}
+
+		if (jfilters != NIL ||
+			add_partial_path_precheck(joinrel, workspace.disabled_nodes,
+									  workspace.startup_cost,
+									  workspace.total_cost, NIL))
+		{
+			Path	   *hjpath;
+
+			hjpath = (Path *) create_hashjoin_path(root,
+												   joinrel,
+												   jointype,
+												   &workspace,
+												   extra,
+												   outer_path,
+												   inner_path,
+												   parallel_hash,
+												   extra->restrictlist,
+												   NULL,
+												   hashclauses);
+			set_join_path_expected_filters(hjpath, jfilters);
+
+			/*
+			 * Record the filters this hash join realizes, so
+			 * create_hashjoin_plan can push exactly those down (and no
+			 * others) at plan-creation time.
+			 */
+			((HashPath *) hjpath)->realized_filters = realized;
+
+			add_partial_path(joinrel, hjpath);
+		}
+	}
 }
 
 /*
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index a6f3c3eda10..e1552b967e8 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -972,6 +972,17 @@ add_partial_path(RelOptInfo *parent_rel, Path *new_path)
 		Path	   *old_path = (Path *) lfirst(p1);
 		bool		remove_old = false; /* unless new proves superior */
 		PathKeysComparison keyscmp;
+		bool		filters_match;
+
+		/*
+		 * Paths carrying different sets of expected Bloom filters serve
+		 * different purposes (each may be consumed by a different parent
+		 * join, or none at all), and their cost/row estimates aren't directly
+		 * comparable.  So if the two paths don't expect the same filters,
+		 * keep both and don't let either dominate the other.
+		 */
+		filters_match = expected_filters_equal(new_path->expected_filters,
+											   old_path->expected_filters);
 
 		/* Compare pathkeys. */
 		keyscmp = compare_pathkeys(new_path->pathkeys, old_path->pathkeys);
@@ -980,8 +991,11 @@ add_partial_path(RelOptInfo *parent_rel, Path *new_path)
 		 * Unless pathkeys are incompatible, see if one of the paths dominates
 		 * the other (both in startup and total cost). It may happen that one
 		 * path has lower startup cost, the other has lower total cost.
+		 *
+		 * Treat expected filters just like pathkeys - if the paths expect
+		 * different filters, they can't dominate each other.
 		 */
-		if (keyscmp != PATHKEYS_DIFFERENT)
+		if (keyscmp != PATHKEYS_DIFFERENT && filters_match)
 		{
 			PathCostComparison costcmp;
 
@@ -1028,10 +1042,15 @@ add_partial_path(RelOptInfo *parent_rel, Path *new_path)
 			/*
 			 * new belongs after this old path if it has more disabled nodes
 			 * or if it has the same number of nodes but a greater total cost
+			 *
+			 * Compare the number of filters first, so that the initial path
+			 * has no filters (there always has to be such path).
 			 */
-			if (new_path->disabled_nodes > old_path->disabled_nodes ||
-				(new_path->disabled_nodes == old_path->disabled_nodes &&
-				 new_path->total_cost >= old_path->total_cost))
+			if ((list_length(new_path->expected_filters) > list_length(old_path->expected_filters)) ||
+				((list_length(new_path->expected_filters) == list_length(old_path->expected_filters)) &&
+				 (new_path->disabled_nodes > old_path->disabled_nodes ||
+				  (new_path->disabled_nodes == old_path->disabled_nodes &&
+				   new_path->total_cost >= old_path->total_cost))))
 				insert_at = foreach_current_index(p1) + 1;
 		}
 
-- 
2.50.1 (Apple Git-155)

