From 11c9364bb33d6c6c7a8de9e26bc247e761cb5808 Mon Sep 17 00:00:00 2001
From: Lukas Fittl <lukas@fittl.com>
Date: Sun, 5 Apr 2026 03:39:46 -0700
Subject: [PATCH v13 05/12] Parallel Bitmap Heap Scan: Fix EXPLAIN reporting of
 "Heap Blocks"

Fix the missing accumulation of "Heap Blocks" from parallel query workers
to the leader, causing EXPLAIN (ANALYZE) to only show the leader statistics,
significantly undercounting the true value.

Additionally, add a regression test covering EXPLAIN (ANALYZE) of a
Parallel Bitmap Heap Scan, which previously was not tested at all.

Author: Lukas Fittl <lukas@fittl.com>
Reviewed-by:
Discussion
---
 src/backend/commands/explain.c        | 33 +++++++++++++++++++++------
 src/test/regress/expected/explain.out | 33 +++++++++++++++++++++++++++
 src/test/regress/sql/explain.sql      | 31 +++++++++++++++++++++++++
 3 files changed, 90 insertions(+), 7 deletions(-)

diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index e7550a8ac46..79bd4d9d69e 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -3919,26 +3919,45 @@ show_indexsearches_info(PlanState *planstate, ExplainState *es)
 static void
 show_tidbitmap_info(BitmapHeapScanState *planstate, ExplainState *es)
 {
+	uint64		exact_pages;
+	uint64		lossy_pages;
+
 	if (!es->analyze)
 		return;
 
+	/* Start with leader's stats */
+	exact_pages = planstate->stats.exact_pages;
+	lossy_pages = planstate->stats.lossy_pages;
+
+	/* Accumulate worker stats into node-level totals */
+	if (planstate->sinstrument != NULL)
+	{
+		for (int n = 0; n < planstate->sinstrument->num_workers; n++)
+		{
+			BitmapHeapScanInstrumentation *si = &planstate->sinstrument->sinstrument[n];
+
+			exact_pages += si->exact_pages;
+			lossy_pages += si->lossy_pages;
+		}
+	}
+
 	if (es->format != EXPLAIN_FORMAT_TEXT)
 	{
 		ExplainPropertyUInteger("Exact Heap Blocks", NULL,
-								planstate->stats.exact_pages, es);
+								exact_pages, es);
 		ExplainPropertyUInteger("Lossy Heap Blocks", NULL,
-								planstate->stats.lossy_pages, es);
+								lossy_pages, es);
 	}
 	else
 	{
-		if (planstate->stats.exact_pages > 0 || planstate->stats.lossy_pages > 0)
+		if (exact_pages > 0 || lossy_pages > 0)
 		{
 			ExplainIndentText(es);
 			appendStringInfoString(es->str, "Heap Blocks:");
-			if (planstate->stats.exact_pages > 0)
-				appendStringInfo(es->str, " exact=" UINT64_FORMAT, planstate->stats.exact_pages);
-			if (planstate->stats.lossy_pages > 0)
-				appendStringInfo(es->str, " lossy=" UINT64_FORMAT, planstate->stats.lossy_pages);
+			if (exact_pages > 0)
+				appendStringInfo(es->str, " exact=" UINT64_FORMAT, exact_pages);
+			if (lossy_pages > 0)
+				appendStringInfo(es->str, " lossy=" UINT64_FORMAT, lossy_pages);
 			appendStringInfoChar(es->str, '\n');
 		}
 	}
diff --git a/src/test/regress/expected/explain.out b/src/test/regress/expected/explain.out
index 7c1f26b182c..58c5a512d74 100644
--- a/src/test/regress/expected/explain.out
+++ b/src/test/regress/expected/explain.out
@@ -822,3 +822,36 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove
 (9 rows)
 
 reset work_mem;
+-- Test parallel bitmap heap scan reports per-worker heap block stats.
+CREATE FUNCTION check_parallel_bitmap_heap_scan() RETURNS boolean AS $$
+DECLARE
+    plan_json json;
+    node json;
+BEGIN
+    SET LOCAL enable_seqscan = off;
+    SET LOCAL enable_indexscan = off;
+    SET LOCAL parallel_setup_cost = 0;
+    SET LOCAL parallel_tuple_cost = 0;
+    SET LOCAL min_parallel_table_scan_size = 0;
+    SET LOCAL min_parallel_index_scan_size = 0;
+    SET LOCAL max_parallel_workers_per_gather = 2;
+    SET LOCAL parallel_leader_participation = off;
+
+    EXECUTE 'EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, FORMAT JSON)
+        SELECT count(*) FROM tenk1 WHERE hundred > 1' INTO plan_json;
+
+    node := plan_json->0->'Plan';
+    WHILE node->'Plans' IS NOT NULL AND node->>'Node Type' != 'Bitmap Heap Scan' LOOP
+        node := node->'Plans'->0;
+    END LOOP;
+
+    RETURN COALESCE((node->>'Exact Heap Blocks')::int, 0) > 0;
+END;
+$$ LANGUAGE plpgsql;
+SELECT check_parallel_bitmap_heap_scan() AS parallel_bitmap_instrumentation;
+ parallel_bitmap_instrumentation 
+---------------------------------
+ t
+(1 row)
+
+DROP FUNCTION check_parallel_bitmap_heap_scan;
diff --git a/src/test/regress/sql/explain.sql b/src/test/regress/sql/explain.sql
index ebdab42604b..bac97522053 100644
--- a/src/test/regress/sql/explain.sql
+++ b/src/test/regress/sql/explain.sql
@@ -188,3 +188,34 @@ select explain_filter('explain (analyze,buffers off,costs off) select sum(n) ove
 -- Test tuplestore storage usage in Window aggregate (memory and disk case, final result is disk)
 select explain_filter('explain (analyze,buffers off,costs off) select sum(n) over(partition by m) from (SELECT n < 3 as m, n from generate_series(1,2500) a(n))');
 reset work_mem;
+
+-- Test parallel bitmap heap scan reports per-worker heap block stats.
+CREATE FUNCTION check_parallel_bitmap_heap_scan() RETURNS boolean AS $$
+DECLARE
+    plan_json json;
+    node json;
+BEGIN
+    SET LOCAL enable_seqscan = off;
+    SET LOCAL enable_indexscan = off;
+    SET LOCAL parallel_setup_cost = 0;
+    SET LOCAL parallel_tuple_cost = 0;
+    SET LOCAL min_parallel_table_scan_size = 0;
+    SET LOCAL min_parallel_index_scan_size = 0;
+    SET LOCAL max_parallel_workers_per_gather = 2;
+    SET LOCAL parallel_leader_participation = off;
+
+    EXECUTE 'EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, FORMAT JSON)
+        SELECT count(*) FROM tenk1 WHERE hundred > 1' INTO plan_json;
+
+    node := plan_json->0->'Plan';
+    WHILE node->'Plans' IS NOT NULL AND node->>'Node Type' != 'Bitmap Heap Scan' LOOP
+        node := node->'Plans'->0;
+    END LOOP;
+
+    RETURN COALESCE((node->>'Exact Heap Blocks')::int, 0) > 0;
+END;
+$$ LANGUAGE plpgsql;
+
+SELECT check_parallel_bitmap_heap_scan() AS parallel_bitmap_instrumentation;
+
+DROP FUNCTION check_parallel_bitmap_heap_scan;
-- 
2.47.1

