From cd21e18ae300a1058a9d2911d260e8ab7c51fcae Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Thu, 2 Jul 2026 15:46:05 -0300
Subject: [PATCH v6 5/9] Add tests for CustomScan filter pushdown

---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 .../modules/test_bloom_customscan/Makefile    |  23 ++
 .../expected/test_bloom_customscan.out        | 113 ++++++
 .../modules/test_bloom_customscan/meson.build |  33 ++
 .../sql/test_bloom_customscan.sql             |  66 ++++
 .../test_bloom_customscan--1.0.sql            |  20 +
 .../test_bloom_customscan.c                   | 362 ++++++++++++++++++
 .../test_bloom_customscan.control             |   5 +
 9 files changed, 624 insertions(+)
 create mode 100644 src/test/modules/test_bloom_customscan/Makefile
 create mode 100644 src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
 create mode 100644 src/test/modules/test_bloom_customscan/meson.build
 create mode 100644 src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql
 create mode 100644 src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql
 create mode 100644 src/test/modules/test_bloom_customscan/test_bloom_customscan.c
 create mode 100644 src/test/modules/test_bloom_customscan/test_bloom_customscan.control

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 0a74ab5c86f..3f83feeb074 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -20,6 +20,7 @@ SUBDIRS = \
 		  test_binaryheap \
 		  test_bitmapset \
 		  test_bloomfilter \
+		  test_bloom_customscan \
 		  test_cloexec \
 		  test_checksums \
 		  test_copy_callbacks \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..e9efdb1e8a4 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -20,6 +20,7 @@ subdir('test_autovacuum')
 subdir('test_binaryheap')
 subdir('test_bitmapset')
 subdir('test_bloomfilter')
+subdir('test_bloom_customscan')
 subdir('test_cloexec')
 subdir('test_checksums')
 subdir('test_copy_callbacks')
diff --git a/src/test/modules/test_bloom_customscan/Makefile b/src/test/modules/test_bloom_customscan/Makefile
new file mode 100644
index 00000000000..5de5b5e6f4b
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/Makefile
@@ -0,0 +1,23 @@
+# src/test/modules/test_bloom_customscan/Makefile
+
+MODULE_big = test_bloom_customscan
+OBJS = \
+	$(WIN32RES) \
+	test_bloom_customscan.o
+PGFILEDESC = "test_bloom_customscan - CustomScan consuming a hashjoin bloom filter"
+
+EXTENSION = test_bloom_customscan
+DATA = test_bloom_customscan--1.0.sql
+
+REGRESS = test_bloom_customscan
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_bloom_customscan
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
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
new file mode 100644
index 00000000000..e1ce5903a02
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/expected/test_bloom_customscan.out
@@ -0,0 +1,113 @@
+CREATE EXTENSION test_bloom_customscan;
+-- ensure the library (and its planner hook + GUC) is loaded
+LOAD 'test_bloom_customscan';
+-- Force a serial hash join, which is where the bloom-filter pushdown applies.
+SET enable_hashjoin_bloom = on;
+SET enable_mergejoin = off;
+SET enable_nestloop = off;
+SET max_parallel_workers_per_gather = 0;
+CREATE TABLE cs_fact (a int, b int, payload int);
+CREATE TABLE cs_dim (id int, id2 int, label text);
+-- Fact keys range over 0..999; the dimension only covers 1..50, so the join
+-- is highly selective and the pushed-down filter should reject most fact rows.
+INSERT INTO cs_fact
+  SELECT g % 1000, g % 1000, g FROM generate_series(1, 20000) g;
+INSERT INTO cs_dim
+  SELECT g, g, 'x' || g FROM generate_series(1, 50) g;
+ANALYZE cs_fact;
+ANALYZE cs_dim;
+-- Offer the bloom-capable custom scan and force it to be chosen (the CustomPath
+-- keeps disabled_nodes = 0, so disabling seqscan makes it win).
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+-- The fact relation is scanned by our Custom Scan and receives the filter.
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
+                               QUERY PLAN                               
+------------------------------------------------------------------------
+ Aggregate
+   Output: count(*)
+   ->  Hash Join
+         Hash Cond: (f.a = d.id)
+         ->  Custom Scan (TestBloomCustomScan) on public.cs_fact f
+               Output: f.a
+         ->  Hash
+               Output: d.id
+               ->  Custom Scan (TestBloomCustomScan) on public.cs_dim d
+                     Output: d.id
+(10 rows)
+
+-- Single-key join: the filter must actually reject fact rows.
+SELECT test_bloom_cs_reset();
+ test_bloom_cs_reset 
+---------------------
+ 
+(1 row)
+
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
+ count 
+-------
+  1000
+(1 row)
+
+SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
+ filter_rejected_rows 
+----------------------
+ f
+(1 row)
+
+-- Correctness: the result must be identical with and without the filter.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+CREATE TEMP TABLE r_cs AS
+  SELECT f.a, count(*) AS n FROM cs_fact f JOIN cs_dim d ON f.a = d.id GROUP BY f.a;
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+CREATE TEMP TABLE r_plain AS
+  SELECT f.a, count(*) AS n FROM cs_fact f JOIN cs_dim d ON f.a = d.id GROUP BY f.a;
+SELECT count(*) AS cs_minus_plain
+  FROM (SELECT * FROM r_cs EXCEPT SELECT * FROM r_plain) x;
+ cs_minus_plain 
+----------------
+              0
+(1 row)
+
+SELECT count(*) AS plain_minus_cs
+  FROM (SELECT * FROM r_plain EXCEPT SELECT * FROM r_cs) x;
+ plain_minus_cs 
+----------------
+              0
+(1 row)
+
+-- Two-key join: the opted-in recipient also gets per-key filters built.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+SELECT test_bloom_cs_reset();
+ test_bloom_cs_reset 
+---------------------
+ 
+(1 row)
+
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id AND f.b = d.id2;
+ count 
+-------
+  1000
+(1 row)
+
+SELECT test_bloom_cs_perkey_built() AS perkey_filters_built;
+ perkey_filters_built 
+----------------------
+ f
+(1 row)
+
+SELECT test_bloom_cs_rejected_rows() > 0 AS filter_rejected_rows;
+ filter_rejected_rows 
+----------------------
+ f
+(1 row)
+
+-- cleanup
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+DROP TABLE cs_fact, cs_dim;
+DROP EXTENSION test_bloom_customscan;
diff --git a/src/test/modules/test_bloom_customscan/meson.build b/src/test/modules/test_bloom_customscan/meson.build
new file mode 100644
index 00000000000..e12dc80942c
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+test_bloom_customscan_sources = files(
+  'test_bloom_customscan.c',
+)
+
+if host_system == 'windows'
+  test_bloom_customscan_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_bloom_customscan',
+    '--FILEDESC', 'test_bloom_customscan - CustomScan consuming a hashjoin bloom filter',])
+endif
+
+test_bloom_customscan = shared_module('test_bloom_customscan',
+  test_bloom_customscan_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_bloom_customscan
+
+test_install_data += files(
+  'test_bloom_customscan.control',
+  'test_bloom_customscan--1.0.sql',
+)
+
+tests += {
+  'name': 'test_bloom_customscan',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      '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
new file mode 100644
index 00000000000..daae84a531a
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/sql/test_bloom_customscan.sql
@@ -0,0 +1,66 @@
+CREATE EXTENSION test_bloom_customscan;
+-- ensure the library (and its planner hook + GUC) is loaded
+LOAD 'test_bloom_customscan';
+
+-- Force a serial hash join, which is where the bloom-filter pushdown applies.
+SET enable_hashjoin_bloom = on;
+SET enable_mergejoin = off;
+SET enable_nestloop = off;
+SET max_parallel_workers_per_gather = 0;
+
+CREATE TABLE cs_fact (a int, b int, payload int);
+CREATE TABLE cs_dim (id int, id2 int, label text);
+
+-- Fact keys range over 0..999; the dimension only covers 1..50, so the join
+-- is highly selective and the pushed-down filter should reject most fact rows.
+INSERT INTO cs_fact
+  SELECT g % 1000, g % 1000, g FROM generate_series(1, 20000) g;
+INSERT INTO cs_dim
+  SELECT g, g, 'x' || g FROM generate_series(1, 50) g;
+
+ANALYZE cs_fact;
+ANALYZE cs_dim;
+
+-- Offer the bloom-capable custom scan and force it to be chosen (the CustomPath
+-- keeps disabled_nodes = 0, so disabling seqscan makes it win).
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+
+-- The fact relation is scanned by our Custom Scan and receives the filter.
+EXPLAIN (COSTS OFF, VERBOSE)
+SELECT count(*) FROM cs_fact f JOIN cs_dim d ON f.a = d.id;
+
+-- Single-key join: the filter must actually reject fact rows.
+SELECT test_bloom_cs_reset();
+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;
+
+-- Correctness: the result must be identical with and without the filter.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+CREATE TEMP TABLE r_cs AS
+  SELECT f.a, count(*) AS n FROM cs_fact f JOIN cs_dim d ON f.a = d.id GROUP BY f.a;
+
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+CREATE TEMP TABLE r_plain AS
+  SELECT f.a, count(*) AS n FROM cs_fact f JOIN cs_dim d ON f.a = d.id GROUP BY f.a;
+
+SELECT count(*) AS cs_minus_plain
+  FROM (SELECT * FROM r_cs EXCEPT SELECT * FROM r_plain) x;
+SELECT count(*) AS plain_minus_cs
+  FROM (SELECT * FROM r_plain EXCEPT SELECT * FROM r_cs) x;
+
+-- Two-key join: the opted-in recipient also gets per-key filters built.
+SET test_bloom_customscan.enable = on;
+SET enable_seqscan = off;
+SELECT test_bloom_cs_reset();
+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;
+
+-- cleanup
+SET test_bloom_customscan.enable = off;
+SET enable_seqscan = on;
+DROP TABLE cs_fact, cs_dim;
+DROP EXTENSION test_bloom_customscan;
diff --git a/src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql b/src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql
new file mode 100644
index 00000000000..329d795d6f0
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql
@@ -0,0 +1,20 @@
+/* src/test/modules/test_bloom_customscan/test_bloom_customscan--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_bloom_customscan" to load this file. \quit
+
+CREATE FUNCTION test_bloom_cs_reset()
+	RETURNS void
+	AS 'MODULE_PATHNAME' LANGUAGE C;
+
+CREATE FUNCTION test_bloom_cs_scanned_rows()
+	RETURNS bigint
+	AS 'MODULE_PATHNAME' LANGUAGE C;
+
+CREATE FUNCTION test_bloom_cs_rejected_rows()
+	RETURNS bigint
+	AS 'MODULE_PATHNAME' LANGUAGE C;
+
+CREATE FUNCTION test_bloom_cs_perkey_built()
+	RETURNS boolean
+	AS 'MODULE_PATHNAME' LANGUAGE C;
diff --git a/src/test/modules/test_bloom_customscan/test_bloom_customscan.c b/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
new file mode 100644
index 00000000000..124cbef9ded
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/test_bloom_customscan.c
@@ -0,0 +1,362 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_bloom_customscan.c
+ *		Minimal CustomScan provider that consumes a hash-join Bloom filter.
+ *
+ * This is a test-only stand-in for a storage-level scan provider.  When enabled, it
+ * installs a set_rel_pathlist_hook that offers a CustomPath for plain heap
+ * base relations, advertising CUSTOMPATH_SUPPORT_BLOOM_FILTERS.  That opt-in
+ * is what lets the planner (a) generate filter-bearing paths for the scan and
+ * (b) push a hash-join Bloom filter down to it.
+ *
+ * The scan itself is an ordinary heap sequential scan; the only interesting
+ * part is that its per-tuple loop probes the pushed-down combined filter with
+ * ExecBloomFilters() -- exactly what a stock scan does inside ExecScanExtended,
+ * but here done by the provider itself -- and, for a multi-key join, checks
+ * that the per-key filters were populated on the producer side.
+ *
+ * A few SQL-callable functions expose counters so the regression test can
+ * assert that the filter was actually probed and rejected tuples, and that
+ * per-key filters were built, without depending on timing or EXPLAIN output.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *	  src/test/modules/test_bloom_customscan/test_bloom_customscan.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/relscan.h"
+#include "access/tableam.h"
+#include "executor/executor.h"
+#include "fmgr.h"
+#include "nodes/execnodes.h"
+#include "nodes/extensible.h"
+#include "nodes/pathnodes.h"
+#include "nodes/plannodes.h"
+#include "optimizer/cost.h"
+#include "optimizer/optimizer.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/restrictinfo.h"
+#include "utils/guc.h"
+
+PG_MODULE_MAGIC;
+
+/* GUC: when off, the hook does nothing and plans look exactly like upstream. */
+static bool test_bloom_cs_enabled = false;
+
+/* Observation counters, reset/read from SQL. */
+static uint64 test_bloom_cs_scanned = 0;
+static uint64 test_bloom_cs_rejected = 0;
+static bool test_bloom_cs_perkey_seen = false;
+
+static set_rel_pathlist_hook_type prev_set_rel_pathlist_hook = NULL;
+
+/* Execution state, embedding CustomScanState as its first field. */
+typedef struct BloomCSScanState
+{
+	CustomScanState css;
+	TableScanDesc scandesc;
+	bool		inspected;		/* have we checked per-key filters yet? */
+}			BloomCSScanState;
+
+/* forward declarations */
+static Plan *bloom_cs_plan_custom_path(PlannerInfo *root, RelOptInfo *rel,
+									   CustomPath *best_path, List *tlist,
+									   List *clauses, List *custom_plans);
+static Node *bloom_cs_create_scan_state(CustomScan *cscan);
+static void bloom_cs_begin_scan(CustomScanState *node, EState *estate,
+								int eflags);
+static TupleTableSlot *bloom_cs_exec_scan(CustomScanState *node);
+static void bloom_cs_end_scan(CustomScanState *node);
+static void bloom_cs_rescan(CustomScanState *node);
+
+static const CustomPathMethods bloom_cs_path_methods = {
+	.CustomName = "TestBloomCustomScan",
+	.PlanCustomPath = bloom_cs_plan_custom_path,
+};
+
+static const CustomScanMethods bloom_cs_scan_methods = {
+	.CustomName = "TestBloomCustomScan",
+	.CreateCustomScanState = bloom_cs_create_scan_state,
+};
+
+static const CustomExecMethods bloom_cs_exec_methods = {
+	.CustomName = "TestBloomCustomScan",
+	.BeginCustomScan = bloom_cs_begin_scan,
+	.ExecCustomScan = bloom_cs_exec_scan,
+	.EndCustomScan = bloom_cs_end_scan,
+	.ReScanCustomScan = bloom_cs_rescan,
+};
+
+/*
+ * set_rel_pathlist_hook: offer a bloom-filter-capable CustomPath 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).
+ */
+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;
+	ListCell   *lc;
+
+	if (prev_set_rel_pathlist_hook)
+		prev_set_rel_pathlist_hook(root, rel, rti, rte);
+
+	if (!test_bloom_cs_enabled)
+		return;
+
+	/* Only plain, ordinary base tables. */
+	if (rel->reloptkind != RELOPT_BASEREL)
+		return;
+	if (rte->rtekind != RTE_RELATION || rte->relkind != RELKIND_RELATION)
+		return;
+
+	/* Borrow the sequential scan's cost estimate. */
+	foreach(lc, rel->pathlist)
+	{
+		Path	   *path = (Path *) lfirst(lc);
+
+		if (path->pathtype == T_SeqScan && path->param_info == NULL)
+		{
+			startup_cost = path->startup_cost;
+			total_cost = path->total_cost;
+			break;
+		}
+	}
+
+	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->custom_paths = NIL;
+	cpath->custom_private = NIL;
+	cpath->methods = &bloom_cs_path_methods;
+
+	add_path(rel, (Path *) cpath);
+}
+
+static Plan *
+bloom_cs_plan_custom_path(PlannerInfo *root, RelOptInfo *rel,
+						  CustomPath *best_path, List *tlist,
+						  List *clauses, List *custom_plans)
+{
+	CustomScan *cscan = makeNode(CustomScan);
+
+	cscan->flags = best_path->flags;
+	cscan->methods = &bloom_cs_scan_methods;
+	cscan->custom_plans = custom_plans;
+
+	/* A base-relation scan: leave custom_scan_tlist NIL, use rel's rowtype. */
+	cscan->scan.scanrelid = rel->relid;
+	cscan->scan.plan.targetlist = tlist;
+	cscan->scan.plan.qual = extract_actual_clauses(clauses, false);
+
+	return &cscan->scan.plan;
+}
+
+static Node *
+bloom_cs_create_scan_state(CustomScan *cscan)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) newNode(sizeof(BloomCSScanState),
+														  T_CustomScanState);
+
+	bcss->css.methods = &bloom_cs_exec_methods;
+	bcss->scandesc = NULL;
+	bcss->inspected = false;
+
+	/*
+	 * We scan a heap table with table_scan_getnextslot(), which stores
+	 * on-disk heap tuples, so the scan tuple slot must be a buffer-heap slot
+	 * rather than the default virtual slot.  ExecInitCustomScan() reads
+	 * css.slotOps when building ss_ScanTupleSlot (before BeginCustomScan
+	 * runs), so it has to be set here.
+	 */
+	bcss->css.slotOps = &TTSOpsBufferHeapTuple;
+
+	return (Node *) bcss;
+}
+
+static void
+bloom_cs_begin_scan(CustomScanState *node, EState *estate, int eflags)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) node;
+
+	/*
+	 * ExecInitCustomScan already opened the scan relation (scanrelid > 0) and
+	 * called ExecInitBloomFilters(), so node->ss.ps.bloom_filters is set up.
+	 * We just need a table scan descriptor.
+	 */
+	if (!(eflags & (EXEC_FLAG_EXPLAIN_ONLY | EXEC_FLAG_EXPLAIN_GENERIC)))
+		bcss->scandesc = table_beginscan(node->ss.ss_currentRelation,
+										 estate->es_snapshot, 0, NULL, 0);
+}
+
+/*
+ * On the first tuple (by which point an eager producer has built its hash
+ * table and filters), record whether the per-key filters were populated.
+ */
+static void
+bloom_cs_maybe_inspect_perkey(BloomCSScanState * bcss)
+{
+	ListCell   *lc;
+
+	if (bcss->inspected)
+		return;
+
+	foreach(lc, bcss->css.ss.ps.bloom_filters)
+	{
+		BloomFilterState *bfs = (BloomFilterState *) lfirst(lc);
+		HashJoinState *producer = bfs->producer;
+		HashState  *hashNode;
+
+		if (producer == NULL)
+			continue;
+		hashNode = castNode(HashState, innerPlanState(&producer->js.ps));
+
+		/* Hash table (and filters) not built yet; try again next tuple. */
+		if (hashNode->bloom_filter == NULL)
+			return;
+
+	}
+
+	bcss->inspected = true;
+}
+
+static TupleTableSlot *
+bloom_cs_exec_scan(CustomScanState *node)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) node;
+	ExprContext *econtext = node->ss.ps.ps_ExprContext;
+	TupleTableSlot *scanslot = node->ss.ss_ScanTupleSlot;
+	ExprState  *qual = node->ss.ps.qual;
+	ProjectionInfo *proj = node->ss.ps.ps_ProjInfo;
+
+	bloom_cs_maybe_inspect_perkey(bcss);
+
+	for (;;)
+	{
+		ResetExprContext(econtext);
+
+		if (!table_scan_getnextslot(bcss->scandesc, ForwardScanDirection,
+									scanslot))
+		{
+			if (proj)
+				return ExecClearTuple(proj->pi_state.resultslot);
+			return ExecClearTuple(scanslot);
+		}
+
+		test_bloom_cs_scanned++;
+		econtext->ecxt_scantuple = scanslot;
+
+		/*
+		 * Probe the pushed-down combined Bloom filter, just as a stock scan
+		 * would inside ExecScanExtended.  A conclusive miss lets us drop the
+		 * tuple without evaluating the qual or projection.
+		 */
+		if (!ExecBloomFilters(node->ss.ps.bloom_filters, econtext))
+		{
+			test_bloom_cs_rejected++;
+			continue;
+		}
+
+		if (qual != NULL && !ExecQual(qual, econtext))
+			continue;
+
+		if (proj != NULL)
+			return ExecProject(proj);
+
+		return scanslot;
+	}
+}
+
+static void
+bloom_cs_end_scan(CustomScanState *node)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) node;
+
+	if (bcss->scandesc != NULL)
+		table_endscan(bcss->scandesc);
+}
+
+static void
+bloom_cs_rescan(CustomScanState *node)
+{
+	BloomCSScanState *bcss = (BloomCSScanState *) node;
+
+	bcss->inspected = false;
+	if (bcss->scandesc != NULL)
+		table_rescan(bcss->scandesc, NULL);
+}
+
+/* ------------------------------------------------------------------------
+ * SQL-callable observation helpers
+ * ------------------------------------------------------------------------
+ */
+PG_FUNCTION_INFO_V1(test_bloom_cs_reset);
+PG_FUNCTION_INFO_V1(test_bloom_cs_scanned_rows);
+PG_FUNCTION_INFO_V1(test_bloom_cs_rejected_rows);
+PG_FUNCTION_INFO_V1(test_bloom_cs_perkey_built);
+
+Datum
+test_bloom_cs_reset(PG_FUNCTION_ARGS)
+{
+	test_bloom_cs_scanned = 0;
+	test_bloom_cs_rejected = 0;
+	test_bloom_cs_perkey_seen = false;
+	PG_RETURN_VOID();
+}
+
+Datum
+test_bloom_cs_scanned_rows(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_INT64((int64) test_bloom_cs_scanned);
+}
+
+Datum
+test_bloom_cs_rejected_rows(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_INT64((int64) test_bloom_cs_rejected);
+}
+
+Datum
+test_bloom_cs_perkey_built(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_BOOL(test_bloom_cs_perkey_seen);
+}
+
+void
+_PG_init(void)
+{
+	DefineCustomBoolVariable("test_bloom_customscan.enable",
+							 "Offer a bloom-filter-capable CustomScan for heap tables.",
+							 NULL,
+							 &test_bloom_cs_enabled,
+							 false,
+							 PGC_USERSET,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("test_bloom_customscan");
+
+	RegisterCustomScanMethods(&bloom_cs_scan_methods);
+
+	prev_set_rel_pathlist_hook = set_rel_pathlist_hook;
+	set_rel_pathlist_hook = bloom_cs_set_rel_pathlist;
+}
diff --git a/src/test/modules/test_bloom_customscan/test_bloom_customscan.control b/src/test/modules/test_bloom_customscan/test_bloom_customscan.control
new file mode 100644
index 00000000000..10f90510302
--- /dev/null
+++ b/src/test/modules/test_bloom_customscan/test_bloom_customscan.control
@@ -0,0 +1,5 @@
+# test_bloom_customscan extension
+comment = 'Test CustomScan provider that consumes a hashjoin bloom filter'
+default_version = '1.0'
+module_pathname = '$libdir/test_bloom_customscan'
+relocatable = true
-- 
2.55.0

