From 8dda1c3f8d8030b2d1448759dadce9c8bcf238f1 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Sat, 1 Aug 2026 02:36:49 +0200
Subject: [PATCH v10 21/21] Rework generation of interesting filters

* Drop the count-based candidate cap. find_interesting_bloom_filters()
  now returns every candidate that clears
  bloom_filter_pushdown_threshold instead of trimming to the
  bloom_filter_pushdown_max most selective ones. Selectivity, not an
  arbitrary count, decides what is interesting.

* Apply all candidates at once instead of enumerating subsets. A single
  combination applies all non-overlapping candidates
  most-selective-first, so we no longer build one path per subset.
  bloom_filter_pushdown_max is repurposed to bound how many filters are
  applied simultaneously (the per-tuple probe cost), and the now-unused
  combination_floor GUC is removed.  This avoids the path explosion and
  keeps planning time bounded.

* Cache the interesting filters on the RelOptInfo so
  find_interesting_bloom_filters() runs once per rel, instead of being
  recomputed for core path generation and again by a CustomScan
  provider.

* Export find_bloom_filter_combinations() and apply_expected_filters()
  so a CustomScan provider can build its own filter-aware paths, rather
  than having core construct CustomPaths on its behalf.
---
 .../pg_plan_advice/expected/join_order.out    |  52 ++--
 .../expected/pg_stash_advice.out              |  88 +++----
 .../postgres_fdw/expected/postgres_fdw.out    |  42 ++--
 src/backend/optimizer/path/allpaths.c         | 231 ++++++++++--------
 src/backend/optimizer/path/costsize.c         |  14 +-
 src/backend/utils/misc/guc_parameters.dat     |   4 +-
 src/backend/utils/misc/postgresql.conf.sample |   4 +-
 src/include/nodes/pathnodes.h                 |  10 +
 src/include/optimizer/paths.h                 |   1 +
 .../expected/test_bloom_customscan.out        | 101 +++++++-
 .../sql/test_bloom_customscan.sql             |  51 +++-
 .../test_bloom_customscan.c                   |  74 ++++--
 src/test/regress/expected/join.out            |  20 +-
 13 files changed, 437 insertions(+), 255 deletions(-)

diff --git a/contrib/pg_plan_advice/expected/join_order.out b/contrib/pg_plan_advice/expected/join_order.out
index f4c0fe60027..a274cfa4003 100644
--- a/contrib/pg_plan_advice/expected/join_order.out
+++ b/contrib/pg_plan_advice/expected/join_order.out
@@ -27,24 +27,24 @@ SELECT * FROM jo_fact f
 	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                  QUERY PLAN                  
-----------------------------------------------
+                         QUERY PLAN                         
+------------------------------------------------------------
  Hash Join
-   Hash Cond: (f.dim2_id = d2.id)
-   ->  Hash Join
-         Hash Cond: (f.dim1_id = d1.id)
-         ->  Seq Scan on jo_fact f
-               Bloom Filter 1: keys=(dim2_id)
-         ->  Hash
-               ->  Seq Scan on jo_dim1 d1
-                     Filter: (val1 = 1)
+   Hash Cond: ((f.dim1_id = d1.id) AND (f.dim2_id = d2.id))
+   ->  Seq Scan on jo_fact f
+         Bloom Filter 1: keys=(dim1_id, dim2_id)
    ->  Hash
          Bloom Filter 1
-         ->  Seq Scan on jo_dim2 d2
-               Filter: (val2 = 1)
+         ->  Nested Loop
+               ->  Seq Scan on jo_dim1 d1
+                     Filter: (val1 = 1)
+               ->  Materialize
+                     ->  Seq Scan on jo_dim2 d2
+                           Filter: (val2 = 1)
  Generated Plan Advice:
-   JOIN_ORDER(f d1 d2)
-   HASH_JOIN(d1 d2)
+   JOIN_ORDER(f (d1 d2))
+   NESTED_LOOP_MATERIALIZE(d2)
+   HASH_JOIN((d1 d2))
    SEQ_SCAN(f d1 d2)
    NO_GATHER(f d1 d2)
 (18 rows)
@@ -58,19 +58,17 @@ SELECT * FROM jo_fact f
 	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                  QUERY PLAN                  
-----------------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Hash Join
    Hash Cond: (f.dim2_id = d2.id)
    ->  Hash Join
          Hash Cond: (f.dim1_id = d1.id)
          ->  Seq Scan on jo_fact f
-               Bloom Filter 1: keys=(dim2_id)
          ->  Hash
                ->  Seq Scan on jo_dim1 d1
                      Filter: (val1 = 1)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on jo_dim2 d2
                Filter: (val2 = 1)
  Supplied Plan Advice:
@@ -80,7 +78,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN(d1 d2)
    SEQ_SCAN(f d1 d2)
    NO_GATHER(f d1 d2)
-(20 rows)
+(18 rows)
 
 SET LOCAL pg_plan_advice.advice = 'join_order(f d2 d1)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
@@ -88,16 +86,14 @@ SELECT * FROM jo_fact f
 	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                  QUERY PLAN                  
-----------------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Hash Join
    Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
          Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on jo_fact f
-               Bloom Filter 1: keys=(dim2_id)
          ->  Hash
-               Bloom Filter 1
                ->  Seq Scan on jo_dim2 d2
                      Filter: (val2 = 1)
    ->  Hash
@@ -110,7 +106,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN(d2 d1)
    SEQ_SCAN(f d2 d1)
    NO_GATHER(f d1 d2)
-(20 rows)
+(18 rows)
 
 SET LOCAL pg_plan_advice.advice = 'join_order(d1 f d2)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
@@ -209,8 +205,8 @@ SELECT * FROM jo_fact f
 	LEFT JOIN jo_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN jo_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                     QUERY PLAN                     
-----------------------------------------------------
+                   QUERY PLAN                   
+------------------------------------------------
  Hash Join
    Hash Cond: (d2.id = f.dim2_id)
    ->  Seq Scan on jo_dim2 d2
@@ -219,9 +215,7 @@ SELECT * FROM jo_fact f
          ->  Hash Join
                Hash Cond: (f.dim1_id = d1.id)
                ->  Seq Scan on jo_fact f
-                     Bloom Filter 1: keys=(dim1_id)
                ->  Hash
-                     Bloom Filter 1
                      ->  Seq Scan on jo_dim1 d1
                            Filter: (val1 = 1)
  Supplied Plan Advice:
@@ -231,7 +225,7 @@ SELECT * FROM jo_fact f
    HASH_JOIN(d1 (f d1))
    SEQ_SCAN(d2 f d1)
    NO_GATHER(f d1 d2)
-(20 rows)
+(18 rows)
 
 SET LOCAL pg_plan_advice.advice = 'join_order(d2 d1)';
 EXPLAIN (COSTS OFF, PLAN_ADVICE)
diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out
index b5390f90b87..f55f22349b0 100644
--- a/contrib/pg_stash_advice/expected/pg_stash_advice.out
+++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out
@@ -57,22 +57,20 @@ EXPLAIN (COSTS OFF)
 SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                  QUERY PLAN                  
-----------------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Hash Join
-   Hash Cond: (f.dim2_id = d2.id)
+   Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
-         Hash Cond: (f.dim1_id = d1.id)
+         Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
-               Bloom Filter 1: keys=(dim2_id)
          ->  Hash
-               ->  Seq Scan on aa_dim1 d1
-                     Filter: (val1 = 1)
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
    ->  Hash
-         Bloom Filter 1
-         ->  Seq Scan on aa_dim2 d2
-               Filter: (val2 = 1)
-(13 rows)
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
 
 -- Force an index scan on dim1
 SELECT pg_set_stashed_advice('regress_stash', :'qid',
@@ -86,24 +84,22 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
 	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                          QUERY PLAN                           
----------------------------------------------------------------
+                       QUERY PLAN                        
+---------------------------------------------------------
  Hash Join
-   Hash Cond: (f.dim2_id = d2.id)
+   Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
-         Hash Cond: (f.dim1_id = d1.id)
+         Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
-               Bloom Filter 1: keys=(dim2_id)
          ->  Hash
-               ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
-                     Filter: (val1 = 1)
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
    ->  Hash
-         Bloom Filter 1
-         ->  Seq Scan on aa_dim2 d2
-               Filter: (val2 = 1)
+         ->  Index Scan using aa_dim1_pkey on aa_dim1 d1
+               Filter: (val1 = 1)
  Supplied Plan Advice:
    INDEX_SCAN(d1 aa_dim1_pkey) /* matched */
-(15 rows)
+(13 rows)
 
 -- Force an alternative join order
 SELECT pg_set_stashed_advice('regress_stash', :'qid',
@@ -117,24 +113,22 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
 	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                  QUERY PLAN                  
-----------------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Hash Join
    Hash Cond: (f.dim2_id = d2.id)
    ->  Hash Join
          Hash Cond: (f.dim1_id = d1.id)
          ->  Seq Scan on aa_fact f
-               Bloom Filter 1: keys=(dim2_id)
          ->  Hash
                ->  Seq Scan on aa_dim1 d1
                      Filter: (val1 = 1)
    ->  Hash
-         Bloom Filter 1
          ->  Seq Scan on aa_dim2 d2
                Filter: (val2 = 1)
  Supplied Plan Advice:
    JOIN_ORDER(f d1 d2) /* matched */
-(15 rows)
+(13 rows)
 
 -- Force an alternative join strategy
 SELECT pg_set_stashed_advice('regress_stash', :'qid',
@@ -208,22 +202,20 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
 	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                  QUERY PLAN                  
-----------------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Hash Join
-   Hash Cond: (f.dim2_id = d2.id)
+   Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
-         Hash Cond: (f.dim1_id = d1.id)
+         Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
-               Bloom Filter 1: keys=(dim2_id)
          ->  Hash
-               ->  Seq Scan on aa_dim1 d1
-                     Filter: (val1 = 1)
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
    ->  Hash
-         Bloom Filter 1
-         ->  Seq Scan on aa_dim2 d2
-               Filter: (val2 = 1)
-(13 rows)
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
 
 -- Test that we can list each stash individually and all of them together,
 -- but not a nonexistent stash.
@@ -273,22 +265,20 @@ EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f
 	LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id
 	LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id
 	WHERE val1 = 1 AND val2 = 1;
-                  QUERY PLAN                  
-----------------------------------------------
+                QUERY PLAN                
+------------------------------------------
  Hash Join
-   Hash Cond: (f.dim2_id = d2.id)
+   Hash Cond: (f.dim1_id = d1.id)
    ->  Hash Join
-         Hash Cond: (f.dim1_id = d1.id)
+         Hash Cond: (f.dim2_id = d2.id)
          ->  Seq Scan on aa_fact f
-               Bloom Filter 1: keys=(dim2_id)
          ->  Hash
-               ->  Seq Scan on aa_dim1 d1
-                     Filter: (val1 = 1)
+               ->  Seq Scan on aa_dim2 d2
+                     Filter: (val2 = 1)
    ->  Hash
-         Bloom Filter 1
-         ->  Seq Scan on aa_dim2 d2
-               Filter: (val2 = 1)
-(13 rows)
+         ->  Seq Scan on aa_dim1 d1
+               Filter: (val1 = 1)
+(11 rows)
 
 SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name;
      stash_name      | num_entries 
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 4760d177ef8..e5b64a507a3 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -1419,7 +1419,7 @@ SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t
  Foreign Scan
    Output: t1.c1, t2.c1, t1.c3
    Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3 FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint
+   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3 FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint
 (4 rows)
 
 SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t1.c1 OFFSET 100 LIMIT 10;
@@ -1445,7 +1445,7 @@ SELECT t1.c1, t2.c2, t3.c3 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) JOIN ft4 t
  Foreign Scan
    Output: t1.c1, t2.c2, t3.c3, t1.c3
    Relations: ((public.ft1 t1) INNER JOIN (public.ft2 t2)) INNER JOIN (public.ft4 t3)
-   Remote SQL: SELECT r1."C 1", r2.c2, r4.c3, r1.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")))) INNER JOIN "S 1"."T 3" r4 ON (((r1."C 1" = r4.c1)))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 10::bigint
+   Remote SQL: SELECT r1."C 1", r2.c2, r4.c3, r1.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")))) INNER JOIN "S 1"."T 3" r4 ON (((r1."C 1" = r4.c1)))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 10::bigint
 (4 rows)
 
 SELECT t1.c1, t2.c2, t3.c3 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) JOIN ft4 t3 ON (t3.c1 = t1.c1) ORDER BY t1.c3, t1.c1 OFFSET 10 LIMIT 10;
@@ -2060,7 +2060,7 @@ SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t
  Foreign Scan
    Output: t1.c1, t2.c1, t1.c3, t1.*, t2.*
    Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint FOR UPDATE OF r1
+   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint FOR UPDATE OF r1
 (4 rows)
 
 SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t1.c1 OFFSET 100 LIMIT 10 FOR UPDATE OF t1;
@@ -2085,7 +2085,7 @@ SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t
  Foreign Scan
    Output: t1.c1, t2.c1, t1.c3, t1.*, t2.*
    Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint FOR UPDATE OF r1 FOR UPDATE OF r2
+   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint FOR UPDATE OF r1 FOR UPDATE OF r2
 (4 rows)
 
 SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t1.c1 OFFSET 100 LIMIT 10 FOR UPDATE;
@@ -2111,7 +2111,7 @@ SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t
  Foreign Scan
    Output: t1.c1, t2.c1, t1.c3, t1.*, t2.*
    Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint FOR SHARE OF r1
+   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint FOR SHARE OF r1
 (4 rows)
 
 SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t1.c1 OFFSET 100 LIMIT 10 FOR SHARE OF t1;
@@ -2136,7 +2136,7 @@ SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t
  Foreign Scan
    Output: t1.c1, t2.c1, t1.c3, t1.*, t2.*
    Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint FOR SHARE OF r1 FOR SHARE OF r2
+   Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint FOR SHARE OF r1 FOR SHARE OF r2
 (4 rows)
 
 SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER BY t1.c3, t1.c1 OFFSET 100 LIMIT 10 FOR SHARE;
@@ -2165,7 +2165,7 @@ WITH t (c1_1, c1_3, c2_1) AS MATERIALIZED (SELECT t1.c1, t1.c3, t2.c1 FROM ft1 t
      ->  Foreign Scan
            Output: t1.c1, t1.c3, t2.c1
            Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-           Remote SQL: SELECT r1."C 1", r1.c3, r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1"))))
+           Remote SQL: SELECT r1."C 1", r1.c3, r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1"))))
    ->  Sort
          Output: t.c1_1, t.c2_1, t.c1_3
          Sort Key: t.c1_3, t.c1_1
@@ -2196,7 +2196,7 @@ SELECT t1.ctid, t1, t2, t1.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) ORDER B
  Foreign Scan
    Output: t1.ctid, t1.*, t2.*, t1.c1, t1.c3
    Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-   Remote SQL: SELECT r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END, r1."C 1", r1.c3 FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint
+   Remote SQL: SELECT r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END, r1."C 1", r1.c3 FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint
 (4 rows)
 
 -- SEMI JOIN
@@ -2207,7 +2207,7 @@ SELECT t1.c1 FROM ft1 t1 WHERE EXISTS (SELECT 1 FROM ft2 t2 WHERE t1.c1 = t2.c1)
  Foreign Scan
    Output: t1.c1
    Relations: (public.ft1 t1) SEMI JOIN (public.ft2 t2)
-   Remote SQL: SELECT r1."C 1" FROM "S 1"."T 1" r1 WHERE EXISTS (SELECT NULL FROM "S 1"."T 1" r2 WHERE ((r1."C 1" = r2."C 1"))) ORDER BY r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint
+   Remote SQL: SELECT r1."C 1" FROM "S 1"."T 1" r1 WHERE EXISTS (SELECT NULL FROM "S 1"."T 1" r2 WHERE ((r2."C 1" = r1."C 1"))) ORDER BY r1."C 1" ASC NULLS LAST LIMIT 10::bigint OFFSET 100::bigint
 (4 rows)
 
 SELECT t1.c1 FROM ft1 t1 WHERE EXISTS (SELECT 1 FROM ft2 t2 WHERE t1.c1 = t2.c1) ORDER BY t1.c1 OFFSET 100 LIMIT 10;
@@ -2408,7 +2408,7 @@ SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) WHERE t1.c8 = t2.
                Output: t1.c1, t2.c1, t1.c3
                Filter: (t1.c8 = t2.c8)
                Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-               Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, r1.c8, r2.c8 FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1"))))
+               Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3, r1.c8, r2.c8 FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1"))))
 (10 rows)
 
 SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) WHERE t1.c8 = t2.c8 ORDER BY t1.c3, t1.c1 OFFSET 100 LIMIT 10;
@@ -2446,11 +2446,11 @@ SELECT t1c1, avg(t1c1 + t2c1) FROM (SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2
                            ->  Foreign Scan
                                  Output: t1.c1, t2.c1
                                  Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-                                 Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1"))))
+                                 Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1"))))
                            ->  Foreign Scan
                                  Output: t1_1.c1, t2_1.c1
                                  Relations: (public.ft1 t1_1) INNER JOIN (public.ft2 t2_1)
-                                 Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1"))))
+                                 Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1"))))
 (20 rows)
 
 SELECT t1c1, avg(t1c1 + t2c1) FROM (SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1) UNION SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1)) AS t (t1c1, t2c1) GROUP BY t1c1 ORDER BY t1c1 OFFSET 100 LIMIT 10;
@@ -2489,7 +2489,7 @@ SELECT t1."C 1" FROM "S 1"."T 1" t1, LATERAL (SELECT DISTINCT t2.c1, t3.c1 FROM
                            ->  Foreign Scan
                                  Output: t2.c1, t3.c1
                                  Relations: (public.ft1 t2) INNER JOIN (public.ft2 t3)
-                                 Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r1.c2 = $1::integer))))
+                                 Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")) AND ((r1.c2 = $1::integer))))
 (17 rows)
 
 SELECT t1."C 1" FROM "S 1"."T 1" t1, LATERAL (SELECT DISTINCT t2.c1, t3.c1 FROM ft1 t2, ft2 t3 WHERE t2.c1 = t3.c1 AND t2.c2 = t1.c2) q ORDER BY t1."C 1" OFFSET 10 LIMIT 10;
@@ -2520,7 +2520,7 @@ SELECT t1.c1, t2.c1 FROM ft1 t1 JOIN ft2 t2 ON (t1.c1 = t2.c1 AND CURRENT_USER =
          ->  Foreign Scan
                Output: t1.c1, t1.c3, t2.c1
                Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-               Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3 FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST
+               Remote SQL: SELECT r1."C 1", r2."C 1", r1.c3 FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")))) ORDER BY r1.c3 ASC NULLS LAST, r1."C 1" ASC NULLS LAST
 (9 rows)
 
 -- non-Var items in targetlist of the nullable rel of a join preventing
@@ -2623,7 +2623,7 @@ SELECT * FROM ft1, ft2, ft4, ft5, local_tbl WHERE ft1.c1 = ft2.c1 AND ft1.c2 = f
          ->  Foreign Scan
                Output: ft1.c1, ft1.c2, ft1.c3, ft1.c4, ft1.c5, ft1.c6, ft1.c7, ft1.c8, ft1.*, ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft2.*, ft4.c1, ft4.c2, ft4.c3, ft4.*, ft5.c1, ft5.c2, ft5.c3, ft5.*
                Relations: (((public.ft1) INNER JOIN (public.ft2)) INNER JOIN (public.ft4)) INNER JOIN (public.ft5)
-               Remote SQL: SELECT r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END, r3.c1, r3.c2, r3.c3, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r4.c1, r4.c2, r4.c3, CASE WHEN (r4.*)::text IS NOT NULL THEN ROW(r4.c1, r4.c2, r4.c3) END FROM ((("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")) AND ((r2."C 1" < 100)) AND ((r1."C 1" < 100)))) INNER JOIN "S 1"."T 3" r3 ON (((r1.c2 = r3.c1)))) INNER JOIN "S 1"."T 4" r4 ON (((r1.c2 = r4.c1)))) ORDER BY r1.c2 ASC NULLS LAST FOR UPDATE OF r1 FOR UPDATE OF r2 FOR UPDATE OF r3 FOR UPDATE OF r4
+               Remote SQL: SELECT r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END, r3.c1, r3.c2, r3.c3, CASE WHEN (r3.*)::text IS NOT NULL THEN ROW(r3.c1, r3.c2, r3.c3) END, r4.c1, r4.c2, r4.c3, CASE WHEN (r4.*)::text IS NOT NULL THEN ROW(r4.c1, r4.c2, r4.c3) END FROM ((("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")) AND ((r2."C 1" < 100)) AND ((r1."C 1" < 100)))) INNER JOIN "S 1"."T 3" r3 ON (((r1.c2 = r3.c1)))) INNER JOIN "S 1"."T 4" r4 ON (((r1.c2 = r4.c1)))) ORDER BY r1.c2 ASC NULLS LAST FOR UPDATE OF r1 FOR UPDATE OF r2 FOR UPDATE OF r3 FOR UPDATE OF r4
                ->  Merge Join
                      Output: ft1.c1, ft1.c2, ft1.c3, ft1.c4, ft1.c5, ft1.c6, ft1.c7, ft1.c8, ft1.*, ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft2.*, ft4.c1, ft4.c2, ft4.c3, ft4.*, ft5.c1, ft5.c2, ft5.c3, ft5.*
                      Merge Cond: (ft1.c2 = ft5.c1)
@@ -3712,7 +3712,7 @@ select sum(t1.c1), count(t2.c1) from ft1 t1 inner join ft2 t2 on (t1.c1 = t2.c1)
          Output: t1.c1
          Filter: (((((t1.c1 * t2.c1) / (t1.c1 * t2.c1)))::double precision * random()) <= '1'::double precision)
          Relations: (public.ft1 t1) INNER JOIN (public.ft2 t2)
-         Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1"))))
+         Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1"))))
 (7 rows)
 
 -- GROUP BY clause having expressions
@@ -4922,7 +4922,7 @@ EXPLAIN (VERBOSE, COSTS OFF) EXECUTE st2(10, 20);
 ----------------------------------------------------------------------------------------------------------------------------------
  Nested Loop Semi Join
    Output: t1.c1, t1.c2, t1.c3, t1.c4, t1.c5, t1.c6, t1.c7, t1.c8
-   Join Filter: (t1.c3 = t2.c3)
+   Join Filter: (t2.c3 = t1.c3)
    ->  Foreign Scan on public.ft1 t1
          Output: t1.c1, t1.c2, t1.c3, t1.c4, t1.c5, t1.c6, t1.c7, t1.c8
          Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" < 20)) ORDER BY "C 1" ASC NULLS LAST
@@ -4955,7 +4955,7 @@ EXPLAIN (VERBOSE, COSTS OFF) EXECUTE st3(10, 20);
  Foreign Scan
    Output: t1.c1, t1.c2, t1.c3, t1.c4, t1.c5, t1.c6, t1.c7, t1.c8
    Relations: (public.ft1 t1) SEMI JOIN (public.ft2 t2)
-   Remote SQL: SELECT r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8 FROM "S 1"."T 1" r1 WHERE ((r1."C 1" < 20)) AND EXISTS (SELECT NULL FROM "S 1"."T 1" r3 WHERE ((r3."C 1" > 10)) AND ((date(r3.c5) = '1970-01-17'::date)) AND ((r1.c3 = r3.c3))) ORDER BY r1."C 1" ASC NULLS LAST
+   Remote SQL: SELECT r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8 FROM "S 1"."T 1" r1 WHERE ((r1."C 1" < 20)) AND EXISTS (SELECT NULL FROM "S 1"."T 1" r3 WHERE ((r3."C 1" > 10)) AND ((date(r3.c5) = '1970-01-17'::date)) AND ((r3.c3 = r1.c3))) ORDER BY r1."C 1" ASC NULLS LAST
 (4 rows)
 
 EXECUTE st3(10, 20);
@@ -5740,7 +5740,7 @@ EXPLAIN (verbose, costs off)
  Foreign Scan
    Output: ft2.c1, ft2.c2, ft2.c3, ft2.c4, ft2.c5, ft2.c6, ft2.c7, ft2.c8, ft4.c1, ft4.c2, ft4.c3
    Relations: ((((public.ft2) INNER JOIN (public.ft4)) SEMI JOIN (public.ft2 ft2_1)) INNER JOIN (public.ft2 ft2_2)) SEMI JOIN (public.ft4 ft4_1)
-   Remote SQL: SELECT r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8, r6.c1, r6.c2, r6.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r6 ON (((r1.c2 = r6.c1)) AND ((r1."C 1" > 900)))) INNER JOIN "S 1"."T 1" r8 ON (((r1.c2 = r8.c2)))) WHERE EXISTS (SELECT NULL FROM "S 1"."T 3" r9 WHERE ((r1.c2 = r9.c2))) AND EXISTS (SELECT NULL FROM "S 1"."T 1" r7 WHERE ((r6.c2 = r7.c2))) ORDER BY r1."C 1" ASC NULLS LAST LIMIT 10::bigint
+   Remote SQL: SELECT r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8, r6.c1, r6.c2, r6.c3 FROM (("S 1"."T 1" r1 INNER JOIN "S 1"."T 3" r6 ON (((r1.c2 = r6.c1)) AND ((r1."C 1" > 900)))) INNER JOIN "S 1"."T 1" r8 ON (((r1.c2 = r8.c2)))) WHERE EXISTS (SELECT NULL FROM "S 1"."T 3" r9 WHERE ((r1.c2 = r9.c2))) AND EXISTS (SELECT NULL FROM "S 1"."T 1" r7 WHERE ((r7.c2 = r6.c2))) ORDER BY r1."C 1" ASC NULLS LAST LIMIT 10::bigint
 (4 rows)
 
 SELECT ft2.*, ft4.* FROM ft2 INNER JOIN
@@ -5811,7 +5811,7 @@ SELECT ft1.c1 FROM ft1 JOIN ft2 on ft1.c1 = ft2.c1 WHERE
          ->  Foreign Scan
                Output: ft1.c1, ft2.c1
                Relations: (public.ft1) INNER JOIN (public.ft2)
-               Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r1."C 1" = r2."C 1")))) ORDER BY r1."C 1" ASC NULLS LAST
+               Remote SQL: SELECT r1."C 1", r2."C 1" FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = r1."C 1")))) ORDER BY r1."C 1" ASC NULLS LAST
          ->  Foreign Scan
                Output: ft2_1.c1, ft4.c1
                Relations: (public.ft2 ft2_1) INNER JOIN (public.ft4)
@@ -7914,7 +7914,7 @@ UPDATE remt2 SET c2 = remt2.c2 || remt2.c2 FROM loct1 WHERE loct1.c1 = remt2.c1
    Remote SQL: UPDATE public.loct2 SET c2 = $2 WHERE ctid = $1 RETURNING c1, c2
    ->  Nested Loop
          Output: (remt2.c2 || remt2.c2), remt2.ctid, remt2.*, loct1.ctid
-         Join Filter: (remt2.c1 = loct1.c1)
+         Join Filter: (loct1.c1 = remt2.c1)
          ->  Seq Scan on public.loct1
                Output: loct1.ctid, loct1.c1
          ->  Foreign Scan on public.remt2
diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index f1ce51e0bc6..64e717d8ad4 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -1096,9 +1096,10 @@ bloom_build_side_join_ratio(PlannerInfo *root, List *build_sides,
  * fraction is estimated as the semijoin selectivity of those clauses.
  *
  * A candidate is "interesting" only if it is expected to eliminate at least
- * bloom_filter_pushdown_threshold of the rel's tuples.  We keep at most
- * bloom_filter_pushdown_max of the most selective candidates, and return them
- * as a list of ExpectedFilter nodes.
+ * bloom_filter_pushdown_threshold of the rel's tuples.  Every candidate that
+ * clears that bar is returned as an ExpectedFilter node; deciding which of
+ * them to actually combine into a scan path (and how many) is left to
+ * find_bloom_filter_combinations().
  *
  * XXX This needs to be careful to not interfere with the general selectivity
  * estimation, performed by clauselist_selectivity(). We'll estimate the filter
@@ -1128,6 +1129,15 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 	if (rel->reloptkind != RELOPT_BASEREL)
 		return NIL;
 
+	/*
+	 * Return the cached result if we've already computed the interesting
+	 * filters for this rel. Both core path generation and a CustomScan
+	 * provider may ask for them, and the enumeration/selectivity work below
+	 * is not free, so we do it only once per base relation.
+	 */
+	if (rel->bloom_filters_valid)
+		return rel->bloom_filters;
+
 	/* Collect candidate hashjoinable equality clauses for this rel. */
 	candidates = generate_implied_equalities_for_all_columns(root, rel,
 															 bloom_em_matches_anybarevar,
@@ -1429,34 +1439,13 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 	}
 
 	/*
-	 * We have collected all potentially intresting filters. Evaluate
-	 * selectivity of each group and keep only the most interesting filters.
-	 * Filters have to eliminate at least bloom_filter_pushdown_threshold
-	 * tuples, and we keep only bloom_filter_pushdown_max most selective ones.
-	 *
-	 * We only connsider a limited number of interesting filters, to prevent
-	 * path explosion. If we found too many, keep only the most selective ones
-	 * (with smallest surviving fraction of tuples), to bound the number of
-	 * generated paths.
-	 *
-	 * XXX This also aligns with good join orders - those tend to perform the
-	 * most selective joins first. So we get to build the filters soon, even
-	 * if the hashjoin optimization is not disabled.
+	 * Return every candidate that clears bloom_filter_pushdown_threshold.
+	 * Which of them to combine into a scan path, and how many, is decided by
+	 * find_bloom_filter_combinations(); a filter that is weak on its own can
+	 * still be useful combined with others, or to a smart consumer.
 	 */
-	while (list_length(result) > bloom_filter_pushdown_max)
-	{
-		ExpectedFilter *worst = NULL;
-		ListCell   *lcw;
-
-		foreach(lcw, result)
-		{
-			ExpectedFilter *f = (ExpectedFilter *) lfirst(lcw);
-
-			if (worst == NULL || f->selectivity > worst->selectivity)
-				worst = f;
-		}
-		result = list_delete_ptr(result, worst);
-	}
+	rel->bloom_filters = result;
+	rel->bloom_filters_valid = true;
 
 	return result;
 }
@@ -1491,27 +1480,16 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 static void
 generate_expected_filter_paths(PlannerInfo *root, RelOptInfo *rel)
 {
-	List	   *filters;
+	List	   *combinations;
 	List	   *basepaths = NIL;
-	int			nfilters;
-	uint32		combo;
-	ListCell   *lc;
-
-	filters = find_interesting_bloom_filters(root, rel);
-	if (filters == NIL)
-		return;
-
-	nfilters = list_length(filters);
 
 	/*
 	 * Snapshot the existing unparameterized, non-partial scan paths of a
 	 * supported type.  We must snapshot before calling add_path(), which
 	 * mutates rel->pathlist.
 	 */
-	foreach(lc, rel->pathlist)
+	foreach_ptr(Path, path, rel->pathlist)
 	{
-		Path	   *path = (Path *) lfirst(lc);
-
 		/* XXX Is parameterization really a problem? Always? */
 		if (path->param_info != NULL || path->expected_filters != NIL)
 			continue;
@@ -1533,67 +1511,17 @@ generate_expected_filter_paths(PlannerInfo *root, RelOptInfo *rel)
 	if (basepaths == NIL)
 		return;
 
+	combinations = find_bloom_filter_combinations(root, rel);
+	if (combinations == NIL)
+		return;
+
 	/*
-	 * Generate all combinations of the interesting filters. We do that by
-	 * iterating 1 to (2^n-1), which generates all bitmask in between. Those
-	 * are the subsets.
-	 *
-	 * XXX This is a good demonstration why we need to keep the number of
-	 * filters low
-	 *
-	 * XXX Maybe we should also stop adding filters once the other filters
-	 * already eliminate enought tuples. Say, we know F1 alone eliminates 99%
-	 * tuples. Does it make sense to also consider [F1,F2]? Probably not. We
-	 * could track "maximum" sets, and reject combinations containing one of
-	 * those. We'd need to generate sets of increasing size, the iteration
-	 * does not do that. But that's not hard.
+	 * For each combination, build a fresh path from each eligible base path
+	 * via that base path's real constructor (except IndexPath, see the
+	 * function comment above).
 	 */
-	for (combo = 1; combo < ((uint32) 1 << nfilters); combo++)
+	foreach_ptr(List, combo, combinations)
 	{
-		List	   *subset = NIL;
-		Relids		used = NULL;
-		bool		overlap = false;
-		int			i = 0;
-		ListCell   *lcf;
-
-		foreach(lcf, filters)
-		{
-			if (combo & ((uint32) 1 << i))
-				subset = lappend(subset, lfirst(lcf));
-			i++;
-		}
-
-		/*
-		 * Skip combinations that mix filters with overlapping build sides.
-		 * Such filters would be sourced from joins that share build
-		 * relations, so their selectivities are not independent and applying
-		 * them together at the same scan wrong - the result would be correct,
-		 * but the estimates would get smaller.
-		 */
-		foreach(lcf, subset)
-		{
-			ExpectedFilter *f = (ExpectedFilter *) lfirst(lcf);
-
-			if (bms_overlap(used, f->build_relids))
-			{
-				overlap = true;
-				break;
-			}
-			used = bms_add_members(used, f->build_relids);
-		}
-		bms_free(used);
-
-		if (overlap)
-		{
-			list_free(subset);
-			continue;
-		}
-
-		/*
-		 * All filtered paths for this combo share the same expected_filters
-		 * list.  That's safe: the list is never modified, and add_path() only
-		 * ever frees the Path node itself, not its expected_filters.
-		 */
 		foreach_ptr(Path, base, basepaths)
 		{
 			Path	   *newpath = NULL;
@@ -1604,13 +1532,13 @@ generate_expected_filter_paths(PlannerInfo *root, RelOptInfo *rel)
 					if (base->pathtype == T_SeqScan)
 						newpath = create_seqscan_path(root, rel, NULL,
 													  base->parallel_workers,
-													  subset);
+													  combo);
 					else if (base->pathtype == T_SampleScan)
 						newpath = create_samplescan_path(root, rel, NULL,
-														 subset);
+														 combo);
 					break;
 				case T_IndexPath:
-					newpath = create_filtered_scan_path(root, base, subset);
+					newpath = create_filtered_scan_path(root, base, combo);
 					break;
 				case T_BitmapHeapPath:
 					newpath = (Path *)
@@ -1618,20 +1546,20 @@ generate_expected_filter_paths(PlannerInfo *root, RelOptInfo *rel)
 												((BitmapHeapPath *) base)->bitmapqual,
 												NULL, 1.0,
 												base->parallel_workers,
-												subset);
+												combo);
 					break;
 				case T_TidPath:
 					newpath = (Path *)
 						create_tidscan_path(root, rel,
 											((TidPath *) base)->tidquals,
-											NULL, subset);
+											NULL, combo);
 					break;
 				case T_TidRangePath:
 					newpath = (Path *)
 						create_tidrangescan_path(root, rel,
 												 ((TidRangePath *) base)->tidrangequals,
 												 NULL, base->parallel_workers,
-												 subset);
+												 combo);
 					break;
 				default:
 					break;
@@ -1643,6 +1571,95 @@ generate_expected_filter_paths(PlannerInfo *root, RelOptInfo *rel)
 	}
 }
 
+/*
+ * expected_filter_selectivity_cmp
+ *	  list_sort comparator ordering ExpectedFilters by ascending selectivity,
+ *	  i.e. the most selective filter (smallest surviving fraction) first.
+ */
+static int
+expected_filter_selectivity_cmp(const ListCell *a, const ListCell *b)
+{
+	Selectivity sa = ((ExpectedFilter *) lfirst(a))->selectivity;
+	Selectivity sb = ((ExpectedFilter *) lfirst(b))->selectivity;
+
+	if (sa < sb)
+		return -1;
+	if (sa > sb)
+		return 1;
+	return 0;
+}
+
+/*
+ * find_bloom_filter_combinations
+ *	  Find the interesting Bloom filters rel could receive from a hash join
+ *	  above it (see find_interesting_bloom_filters), and return the combination
+ *	  of them worth building a path for, as a list of lists of ExpectedFilter.
+ *
+ * Following the "apply all candidates simultaneously" heuristic (Heuristic 4
+ * of the bottom-up Bloom filter paper), we build a single combination that
+ * applies as many candidates as possible at once.  We take the interesting
+ * filters most selective first and add each one whose build side does not
+ * overlap the build sides already chosen, stopping once
+ * bloom_filter_pushdown_max filters have been collected.
+ *
+ * Overlapping build sides are skipped because such filters would be sourced
+ * from joins sharing build relations, so their selectivities are not
+ * independent and applying them together would under-estimate the surviving
+ * rows.  On such a conflict we keep the more selective filter, since it sorts
+ * first.  bloom_filter_pushdown_max bounds how many filters are applied at
+ * once, which bounds the per-tuple probe cost.
+ *
+ * The result is a list of combinations (each a list of ExpectedFilter); this
+ * heuristic produces at most one combination.
+ *
+ * Note: A CustomScan provider can call it directly from its own
+ * set_rel_pathlist_hook (see generate_expected_filter_paths above).
+ */
+List *
+find_bloom_filter_combinations(PlannerInfo *root, RelOptInfo *rel)
+{
+	List	   *filters = find_interesting_bloom_filters(root, rel);
+	List	   *sorted;
+	List	   *combo = NIL;
+	Relids		used = NULL;
+	ListCell   *lc;
+
+	if (filters == NIL)
+		return NIL;
+
+	/*
+	 * Consider the most selective filters first, so that on a build-side
+	 * conflict we keep the one that discards more rows, and so that the
+	 * bloom_filter_pushdown_max cap retains the most useful filters.  Sort a
+	 * copy: "filters" is cached on the RelOptInfo and must not be reordered.
+	 */
+	sorted = list_copy(filters);
+	list_sort(sorted, expected_filter_selectivity_cmp);
+
+	foreach(lc, sorted)
+	{
+		ExpectedFilter *f = (ExpectedFilter *) lfirst(lc);
+
+		if (list_length(combo) >= bloom_filter_pushdown_max)
+			break;
+
+		/* skip a filter whose build side overlaps one already chosen */
+		if (bms_overlap(used, f->build_relids))
+			continue;
+
+		combo = lappend(combo, f);
+		used = bms_add_members(used, f->build_relids);
+	}
+
+	list_free(sorted);
+	bms_free(used);
+
+	if (combo == NIL)
+		return NIL;
+
+	return list_make1(combo);
+}
+
 /*
  * set_tablesample_rel_size
  *	  Set size estimates for a sampled relation
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index fec7959d8a3..a6deef430f8 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -176,11 +176,11 @@ bool		enable_async_append = true;
 double		bloom_filter_pushdown_threshold = 0.3;
 
 /*
- * Upper bound on the number of distinct interesting Bloom filters considered
- * for a single scan relation.  This bounds the number of additional paths
- * generated per scan (the planner enumerates non-empty subsets of the
- * interesting filters, i.e. up to 2^bloom_filter_pushdown_max - 1 extra
- * paths per base scan path).
+ * Upper bound on the number of interesting Bloom filters that may be
+ * combined into a single filter-aware scan path variant for a base
+ * relation.  This does not limit how many candidate filters are considered
+ * (see bloom_filter_pushdown_threshold) -- only how large a combination of
+ * them the planner is willing to build a path for.
  */
 int			bloom_filter_pushdown_max = 3;
 
@@ -189,8 +189,8 @@ int			bloom_filter_pushdown_max = 3;
  * side.  Bloom filters over larger joins are unlikely to be worthwhile and
  * enumerating them would inflate planning time, so we keep this small.  This
  * bounds the *size* of each candidate build side, which is distinct from
- * bloom_filter_pushdown_max that bounds how many interesting filters are
- * ultimately kept per probe relation.
+ * bloom_filter_pushdown_max, which bounds how many filters are applied
+ * together in a single scan.
  */
 int			bloom_filter_pushdown_max_build_relids = 3;
 
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index 065835e603d..d3525db9c0c 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -381,8 +381,8 @@
 },
 
 { name => 'bloom_filter_pushdown_max', type => 'int', context => 'PGC_USERSET', group => 'QUERY_TUNING_OTHER',
-  short_desc => 'Maximum number of pushed-down hash join bloom filters considered per scan.',
-  long_desc => 'Bounds how many interesting bloom filters the planner enumerates subsets of when building filter-aware scan paths.',
+  short_desc => 'Maximum number of hash join bloom filters combined into a single pushed-down filter set.',
+  long_desc => 'Bounds how many interesting bloom filters the planner will combine together when building filter-aware scan path variants for a base relation.',
   flags => 'GUC_EXPLAIN',
   variable => 'bloom_filter_pushdown_max',
   boot_val => '3',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index d3d9eddf8a0..7554b6a8c2f 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -489,8 +489,8 @@
 
 # - Other Planner Options -
 
-#bloom_filter_pushdown_max = 3          # range 0-10
-#bloom_filter_pushdown_threshold = 0.3  # range 0.0-1.0
+#bloom_filter_pushdown_max = 3                     # range 0-10
+#bloom_filter_pushdown_threshold = 0.3             # range 0.0-1.0
 #bloom_filter_pushdown_max_build_relids = 3      # range 1-100
 #bloom_filter_pushdown_max_build_sets = 100      # range 1-INT_MAX
 #default_statistics_target = 100        # range 1-10000
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index e17eacc3421..835974da7a0 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -1070,6 +1070,16 @@ typedef struct RelOptInfo
 	struct Path *cheapest_total_path;
 	List	   *cheapest_parameterized_paths;
 
+	/*
+	 * Cache of the interesting Bloom filters this rel could receive from a
+	 * hash join above it. Computed lazily and reused, since both core path
+	 * generation and a CustomScan provider may ask for it, and recomputing it
+	 * is not free. A separate "valid" flag distinguishes "not yet computed"
+	 * from "computed, no filters".
+	 */
+	List	   *bloom_filters pg_node_attr(read_write_ignore);
+	bool		bloom_filters_valid pg_node_attr(read_write_ignore);
+
 	/*
 	 * parameterization information needed for both base rels and join rels
 	 * (see also lateral_vars and lateral_referencers)
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index b153df758b3..2ec8a3bf928 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -61,6 +61,7 @@ extern PGDLLIMPORT join_search_hook_type join_search_hook;
 extern RelOptInfo *make_one_rel(PlannerInfo *root, List *joinlist);
 extern RelOptInfo *standard_join_search(PlannerInfo *root, int levels_needed,
 										List *initial_rels);
+extern List *find_bloom_filter_combinations(PlannerInfo *root, RelOptInfo *rel);
 
 extern void generate_gather_paths(PlannerInfo *root, RelOptInfo *rel,
 								  bool override_rows);
diff --git a/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out b/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
index e1ce5903a02..11eea4b134e 100644
--- a/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
+++ b/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
@@ -33,9 +33,10 @@ SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
                Output: f.a
          ->  Hash
                Output: d.id
+               Bloom Filter 1
                ->  Custom Scan (TestBloomCustomScan) on public.cs_dim d
                      Output: d.id
-(10 rows)
+(11 rows)
 
 -- Single-key join: the filter must actually reject fact rows.
 SELECT test_bloom_cs_reset();
@@ -53,7 +54,7 @@ SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
 SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
  filter_rejected_rows 
 ----------------------
- f
+ t
 (1 row)
 
 -- Correctness: the result must be identical with and without the filter.
@@ -97,17 +98,107 @@ SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id AND f.b = d.id2;
 SELECT test_bloom_cs_perkey_built() AS perkey_filters_built;
  perkey_filters_built 
 ----------------------
- f
+ t
 (1 row)
 
 SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
  filter_rejected_rows 
 ----------------------
- f
+ t
+(1 row)
+
+-- Two independent joins: the provider should offer, and the planner should
+-- choose, a CustomPath expecting a combined filter from both joins.
+-- cpu_operator_cost is lowered so combining is robustly cheaper rather than a
+-- near-tie.
+CREATE TABLE cs_wide_a (id int, label text);
+CREATE TABLE cs_wide_b (id int, label text);
+INSERT INTO cs_wide_a SELECT g, 'a' || g FROM generate_series(1, 400) g;
+INSERT INTO cs_wide_b SELECT g, 'b' || g FROM generate_series(1, 400) g;
+ANALYZE cs_wide_a;
+ANALYZE cs_wide_b;
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+SET cpu_operator_cost = 0.0001;
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT count(*) FROM cs_fact f
+  JOIN cs_wide_a wa ON f.a = wa.id
+  JOIN cs_wide_b wb ON f.b = wb.id;
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
+ Aggregate
+   Output: count(*)
+   ->  Hash Join
+         Hash Cond: (f.b = wb.id)
+         ->  Hash Join
+               Output: f.b
+               Hash Cond: (f.a = wa.id)
+               ->  Custom Scan (TestBloomCustomScan) on public.cs_fact f
+                     Output: f.a, f.b
+               ->  Hash
+                     Output: wa.id
+                     Bloom Filter 1
+                     ->  Custom Scan (TestBloomCustomScan) on public.cs_wide_a wa
+                           Output: wa.id
+         ->  Hash
+               Output: wb.id
+               Bloom Filter 2
+               ->  Custom Scan (TestBloomCustomScan) on public.cs_wide_b wb
+                     Output: wb.id
+(19 rows)
+
+SELECT test_bloom_cs_reset();
+ test_bloom_cs_reset 
+---------------------
+ 
+(1 row)
+
+SELECT count(*) FROM cs_fact f
+  JOIN cs_wide_a wa ON f.a = wa.id
+  JOIN cs_wide_b wb ON f.b = wb.id;
+ count 
+-------
+  8000
+(1 row)
+
+SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
+ filter_rejected_rows 
+----------------------
+ t
+(1 row)
+
+-- Correctness: the result must be identical with and without the filters.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+CREATE TEMP TABLE r_cs2 AS
+  SELECT f.a, count(*) AS n FROM cs_fact f
+    JOIN cs_wide_a wa ON f.a = wa.id
+    JOIN cs_wide_b wb ON f.b = wb.id
+  GROUP BY f.a;
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+CREATE TEMP TABLE r_plain2 AS
+  SELECT f.a, count(*) AS n FROM cs_fact f
+    JOIN cs_wide_a wa ON f.a = wa.id
+    JOIN cs_wide_b wb ON f.b = wb.id
+  GROUP BY f.a;
+SELECT count(*) AS cs_minus_plain
+  FROM (SELECT * FROM r_cs2 EXCEPT SELECT * FROM r_plain2) x;
+ cs_minus_plain 
+----------------
+              0
+(1 row)
+
+SELECT count(*) AS plain_minus_cs
+  FROM (SELECT * FROM r_plain2 EXCEPT SELECT * FROM r_cs2) x;
+ plain_minus_cs 
+----------------
+              0
 (1 row)
 
 -- cleanup
+RESET cpu_operator_cost;
 SET test_bloom_customscan.enable = off;
 SET enable_seqscan = on;
-DROP TABLE cs_fact, cs_dim;
+DROP TABLE cs_fact, cs_dim, cs_wide_a, cs_wide_b;
 DROP EXTENSION test_bloom_customscan;
diff --git a/src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql b/src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql
index daae84a531a..8da2e0789e6 100644
--- a/src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql
+++ b/src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql
@@ -59,8 +59,57 @@ SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id AND f.b = d.id2;
 SELECT test_bloom_cs_perkey_built() AS perkey_filters_built;
 SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
 
+-- Two independent joins: the provider should offer, and the planner should
+-- choose, a CustomPath expecting a combined filter from both joins.
+-- cpu_operator_cost is lowered so combining is robustly cheaper rather than a
+-- near-tie.
+CREATE TABLE cs_wide_a (id int, label text);
+CREATE TABLE cs_wide_b (id int, label text);
+INSERT INTO cs_wide_a SELECT g, 'a' || g FROM generate_series(1, 400) g;
+INSERT INTO cs_wide_b SELECT g, 'b' || g FROM generate_series(1, 400) g;
+ANALYZE cs_wide_a;
+ANALYZE cs_wide_b;
+
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+SET cpu_operator_cost = 0.0001;
+
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT count(*) FROM cs_fact f
+  JOIN cs_wide_a wa ON f.a = wa.id
+  JOIN cs_wide_b wb ON f.b = wb.id;
+
+SELECT test_bloom_cs_reset();
+SELECT count(*) FROM cs_fact f
+  JOIN cs_wide_a wa ON f.a = wa.id
+  JOIN cs_wide_b wb ON f.b = wb.id;
+SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
+
+-- Correctness: the result must be identical with and without the filters.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+CREATE TEMP TABLE r_cs2 AS
+  SELECT f.a, count(*) AS n FROM cs_fact f
+    JOIN cs_wide_a wa ON f.a = wa.id
+    JOIN cs_wide_b wb ON f.b = wb.id
+  GROUP BY f.a;
+
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+CREATE TEMP TABLE r_plain2 AS
+  SELECT f.a, count(*) AS n FROM cs_fact f
+    JOIN cs_wide_a wa ON f.a = wa.id
+    JOIN cs_wide_b wb ON f.b = wb.id
+  GROUP BY f.a;
+
+SELECT count(*) AS cs_minus_plain
+  FROM (SELECT * FROM r_cs2 EXCEPT SELECT * FROM r_plain2) x;
+SELECT count(*) AS plain_minus_cs
+  FROM (SELECT * FROM r_plain2 EXCEPT SELECT * FROM r_cs2) x;
+
 -- cleanup
+RESET cpu_operator_cost;
 SET test_bloom_customscan.enable = off;
 SET enable_seqscan = on;
-DROP TABLE cs_fact, cs_dim;
+DROP TABLE cs_fact, cs_dim, cs_wide_a, cs_wide_b;
 DROP EXTENSION test_bloom_customscan;
diff --git a/src/test/modules/test_bloom_customscan/test_bloom_customscan.c b/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
index 99338c7d361..30f53ff45c7 100644
--- a/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
+++ b/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
@@ -64,6 +64,8 @@ typedef struct BloomCSScanState
 }			BloomCSScanState;
 
 /* forward declarations */
+static void bloom_cs_add_path(RelOptInfo *rel, Cost startup_cost,
+							  Cost total_cost, List *filters);
 static Plan *bloom_cs_plan_custom_path(PlannerInfo *root, RelOptInfo *rel,
 									   CustomPath *best_path, List *tlist,
 									   List *clauses, List *custom_plans);
@@ -93,18 +95,57 @@ static const CustomExecMethods bloom_cs_exec_methods = {
 };
 
 /*
- * set_rel_pathlist_hook: offer a bloom-filter-capable CustomPath for plain
+ * bloom_cs_add_path
+ *	  Build and add one bloom-filter-capable CustomPath, optionally expecting
+ *	  the given set of pushed-down filters.  We don't do anything smart with
+ *	  the filters ourselves, so apply_expected_filters() supplies the same
+ *	  generic per-tuple probe cost and row-count adjustment core uses for
+ *	  stock scan types.
+ */
+static void
+bloom_cs_add_path(RelOptInfo *rel, Cost startup_cost, Cost total_cost,
+				  List *filters)
+{
+	CustomPath *cpath = makeNode(CustomPath);
+
+	cpath->path.pathtype = T_CustomScan;
+	cpath->path.parent = rel;
+	cpath->path.pathtarget = rel->reltarget;
+	cpath->path.param_info = NULL;
+	cpath->path.parallel_aware = false;
+	cpath->path.parallel_safe = false;
+	cpath->path.parallel_workers = 0;
+	cpath->path.rows = rel->rows;
+	cpath->path.startup_cost = startup_cost;
+	cpath->path.total_cost = total_cost;
+	cpath->path.pathkeys = NIL;
+
+	cpath->flags = CUSTOMPATH_SUPPORT_BLOOM_FILTERS;
+	cpath->custom_paths = NIL;
+	cpath->custom_private = NIL;
+	cpath->methods = &bloom_cs_path_methods;
+
+	apply_expected_filters(&cpath->path, filters);
+
+	add_path(rel, (Path *) cpath);
+}
+
+/*
+ * set_rel_pathlist_hook: offer bloom-filter-capable CustomPaths for plain
  * heap base relations.  We copy the cost of the existing sequential scan path
- * so join costing stays sane; the test forces the custom scan to be chosen
- * with "SET enable_seqscan = off" (the CustomPath keeps disabled_nodes = 0).
+ * so join costing stays sane.
+ *
+ * Besides the plain, filter-oblivious path, we also offer one CustomPath per
+ * combination of interesting Bloom filters this rel could receive from a
+ * hash join above it.
  */
 static void
 bloom_cs_set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, Index rti,
 						  RangeTblEntry *rte)
 {
-	CustomPath *cpath;
 	Cost		startup_cost = 0;
 	Cost		total_cost = 0;
+	List	   *combinations;
 	ListCell   *lc;
 
 	if (prev_set_rel_pathlist_hook)
@@ -132,25 +173,16 @@ bloom_cs_set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, Index rti,
 		}
 	}
 
-	cpath = makeNode(CustomPath);
-	cpath->path.pathtype = T_CustomScan;
-	cpath->path.parent = rel;
-	cpath->path.pathtarget = rel->reltarget;
-	cpath->path.param_info = NULL;
-	cpath->path.parallel_aware = false;
-	cpath->path.parallel_safe = false;
-	cpath->path.parallel_workers = 0;
-	cpath->path.rows = rel->rows;
-	cpath->path.startup_cost = startup_cost;
-	cpath->path.total_cost = total_cost;
-	cpath->path.pathkeys = NIL;
+	bloom_cs_add_path(rel, startup_cost, total_cost, NIL);
 
-	cpath->flags = CUSTOMPATH_SUPPORT_BLOOM_FILTERS;
-	cpath->custom_paths = NIL;
-	cpath->custom_private = NIL;
-	cpath->methods = &bloom_cs_path_methods;
+	combinations = find_bloom_filter_combinations(root, rel);
 
-	add_path(rel, (Path *) cpath);
+	foreach(lc, combinations)
+	{
+		List	   *combo = (List *) lfirst(lc);
+
+		bloom_cs_add_path(rel, startup_cost, total_cost, combo);
+	}
 }
 
 static Plan *
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index f296952314b..e896a61d386 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -10265,18 +10265,16 @@ select * from fkest f1
 -----------------------------------------------------
  Hash Join
    Hash Cond: ((f2.x = f1.x) AND (f2.x10b = f1.x10))
-   ->  Seq Scan on fkest f2
-         Filter: (x100 = 2)
+   ->  Hash Join
+         Hash Cond: (f3.x = f2.x)
+         ->  Seq Scan on fkest f3
+         ->  Hash
+               ->  Seq Scan on fkest f2
+                     Filter: (x100 = 2)
    ->  Hash
-         ->  Hash Join
-               Hash Cond: (f3.x = f1.x)
-               ->  Seq Scan on fkest f3
-                     Bloom Filter 1: keys=(x)
-               ->  Hash
-                     Bloom Filter 1
-                     ->  Seq Scan on fkest f1
-                           Filter: (x100 = 2)
-(13 rows)
+         ->  Seq Scan on fkest f1
+               Filter: (x100 = 2)
+(11 rows)
 
 rollback;
 --
-- 
2.50.1 (Apple Git-155)

