From b5c3ec87d3ae0421f3617b9779327a052b35b916 Mon Sep 17 00:00:00 2001
From: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Date: Sat, 24 Oct 2026 09:38:46 +0000
Subject: [PATCH v3 8/8] Report buffer access strategies for index scans in
 EXPLAIN

When ANALYZE and BUFFERS are enabled, report every buffer access strategy
selected by the index access method for index, index-only, and bitmap index
scans.

Extend index scan instrumentation with an optional strategy mask and request
it only when buffer instrumentation is enabled. Have each in-core index access
method record its selected strategy in the scan instrumentation. The standard
access methods report normal, while Bloom reports bulkread.

Preserve strategies across rescans and propagate them through parallel worker
instrumentation. Report them only for the leader and workers that execute the
scan. An empty mask allows another index access method to leave the information
unavailable.

Add documentation and TAP coverage for normal and bulkread strategies,
parallel workers, never-executed scans, scans that exit before searching the
index, and omission when BUFFERS is disabled.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reviewed-by: Sami Imseih <samimseih.pg@gmail.com>
Discussion: https://postgr.es/m/auCYYbDyrQEwIsQq@bdtpg
---
 contrib/bloom/blscan.c                     |   1 +
 contrib/bloom/meson.build                  |   1 +
 contrib/bloom/t/002_explain.pl             | 259 +++++++++++++++++++++
 doc/src/sgml/ref/explain.sgml              |   8 +-
 src/backend/access/brin/brin.c             |   1 +
 src/backend/access/gin/ginget.c            |   3 +
 src/backend/access/gist/gistget.c          |   2 +
 src/backend/access/hash/hashsearch.c       |   1 +
 src/backend/access/nbtree/nbtsearch.c      |   2 +
 src/backend/access/spgist/spgscan.c        |   6 +
 src/backend/commands/explain.c             |  51 +++-
 src/backend/executor/nodeBitmapIndexscan.c |   6 +-
 src/backend/executor/nodeIndexonlyscan.c   |   7 +-
 src/backend/executor/nodeIndexscan.c       |   6 +-
 src/include/executor/instrument_node.h     |  37 +++
 15 files changed, 375 insertions(+), 16 deletions(-)
  60.3% contrib/bloom/t/
   4.0% doc/src/sgml/ref/
   4.8% src/backend/access/
  15.9% src/backend/commands/
   6.1% src/backend/executor/
   7.8% src/include/executor/

diff --git a/contrib/bloom/blscan.c b/contrib/bloom/blscan.c
index 1a0e42021ec..ecb519d704a 100644
--- a/contrib/bloom/blscan.c
+++ b/contrib/bloom/blscan.c
@@ -117,6 +117,7 @@ blgetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	 * We're going to read the whole index. This is why we use appropriate
 	 * buffer access strategy.
 	 */
+	InstrRecordIndexScanStrategy(scan->instrument, BAS_BULKREAD);
 	bas = GetAccessStrategy(BAS_BULKREAD);
 	npages = RelationGetNumberOfBlocks(scan->indexRelation);
 	pgstat_count_index_scan(scan->indexRelation);
diff --git a/contrib/bloom/meson.build b/contrib/bloom/meson.build
index fa4f4ea796b..3e6a5667ef1 100644
--- a/contrib/bloom/meson.build
+++ b/contrib/bloom/meson.build
@@ -40,6 +40,7 @@ tests += {
   'tap': {
     'tests': [
       't/001_wal.pl',
+      't/002_explain.pl',
     ],
   },
 }
diff --git a/contrib/bloom/t/002_explain.pl b/contrib/bloom/t/002_explain.pl
new file mode 100644
index 00000000000..1eb959d4bac
--- /dev/null
+++ b/contrib/bloom/t/002_explain.pl
@@ -0,0 +1,259 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test EXPLAIN reporting of buffer access strategies for index scans,
+# including different access methods, early exits, and parallel execution.
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP qw(decode_json);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+
+# Keep the test table below the bulk-read threshold
+$node->append_conf('postgresql.conf', 'shared_buffers = 1MB');
+$node->start;
+
+$node->safe_psql(
+	'postgres', qq{
+CREATE EXTENSION bloom;
+CREATE TABLE tst AS SELECT i % 10 AS i FROM generate_series(1, 2000) AS i;
+CREATE INDEX bloomidx ON tst USING bloom (i);
+CREATE TABLE btree_tst AS
+  SELECT i, i AS payload FROM generate_series(1, 2000) AS i;
+CREATE INDEX btreeidx ON btree_tst (i);
+CREATE TABLE gin_tst AS
+  SELECT ARRAY[i] AS a FROM generate_series(1, 2000) AS i;
+CREATE INDEX ginidx ON gin_tst USING gin (a);
+});
+
+my $btree_plan = explain_btree_json();
+my $btree_scan = find_plan_node($btree_plan, 'Index Scan');
+
+ok(defined $btree_scan, 'plan contains a B-tree index scan');
+is_deeply($btree_scan->{'Buffer Access Strategies'},
+	['normal'], 'B-tree index scan uses the normal strategy');
+
+my $btree_index_only_plan = explain_btree_index_only_json();
+my $btree_index_only_scan =
+  find_plan_node($btree_index_only_plan, 'Index Only Scan');
+
+ok(defined $btree_index_only_scan, 'plan contains a B-tree index-only scan');
+is_deeply($btree_index_only_scan->{'Buffer Access Strategies'},
+	['normal'], 'B-tree index-only scan uses the normal strategy');
+
+my $btree_null_plan = explain_btree_null_json();
+my $btree_null_scan = find_plan_node($btree_null_plan, 'Index Scan');
+
+ok(defined $btree_null_scan,
+	'plan contains a B-tree index scan for a NULL parameter');
+is($btree_null_scan->{'Index Searches'},
+	0, 'B-tree exits before searching the index for a NULL parameter');
+ok( !exists $btree_null_scan->{'Buffer Access Strategies'},
+	'strategy is not shown when B-tree does not search the index');
+
+my $never_executed_plan = explain_never_executed_gin_json();
+my $never_executed_scan =
+  find_never_executed_plan_node($never_executed_plan, 'Bitmap Index Scan');
+
+ok(defined $never_executed_scan,
+	'plan contains a never-executed GIN index scan');
+ok( !exists $never_executed_scan->{'Buffer Access Strategies'},
+	'strategy is not shown for a never-executed index scan');
+
+my $plan = explain_json('BUFFERS');
+my $heap_scan = find_plan_node($plan, 'Bitmap Heap Scan');
+my $index_scan = find_plan_node($plan, 'Bitmap Index Scan');
+
+ok(defined $heap_scan, 'plan contains a bitmap heap scan');
+is_deeply($heap_scan->{'Buffer Access Strategies'},
+	['normal'], 'bitmap heap scan uses the normal strategy');
+ok(defined $index_scan, 'plan contains a bitmap index scan');
+is_deeply($index_scan->{'Buffer Access Strategies'},
+	['bulkread'], 'bloom index scan uses the bulkread strategy');
+
+my $null_plan = explain_bloom_null_json();
+my $null_index_scan = find_plan_node($null_plan, 'Bitmap Index Scan');
+
+ok(defined $null_index_scan,
+	'plan contains a Bloom bitmap index scan for a NULL parameter');
+is($null_index_scan->{'Index Searches'},
+	0, 'Bloom exits before searching the index for a NULL parameter');
+ok( !exists $null_index_scan->{'Buffer Access Strategies'},
+	'strategy is not shown when Bloom does not search the index');
+
+my $parallel_plan = explain_parallel_json();
+my $gather = find_plan_node($parallel_plan, 'Gather');
+my $parallel_index_scan = find_plan_node($parallel_plan, 'Bitmap Index Scan');
+
+ok(defined $gather, 'parallel plan contains a Gather');
+cmp_ok(defined $gather ? $gather->{'Workers Launched'} : 0,
+	'>', 0, 'parallel worker launched');
+ok(defined $parallel_index_scan,
+	'parallel plan contains a bitmap index scan');
+ok( !exists $parallel_index_scan->{'Buffer Access Strategies'},
+	'strategy is not shown for the non-participating leader');
+my $workers = $parallel_index_scan->{'Workers'};
+ok(ref $workers eq 'ARRAY' && @$workers > 0,
+	'parallel bitmap index scan reports worker information');
+
+foreach my $worker (@$workers)
+{
+	is_deeply($worker->{'Buffer Access Strategies'},
+		['bulkread'], 'parallel worker uses the bulkread strategy');
+}
+
+my $without_buffers = explain_json('BUFFERS OFF');
+$index_scan = find_plan_node($without_buffers, 'Bitmap Index Scan');
+
+ok( defined $index_scan && !exists $index_scan->{'Buffer Access Strategies'},
+	'strategy is not shown when BUFFERS is disabled');
+
+$node->stop;
+
+done_testing();
+
+# Run EXPLAIN for a B-tree index scan and return the top-level plan.
+sub explain_btree_json
+{
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
+  SELECT * FROM btree_tst WHERE i = 7;
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Run EXPLAIN for a B-tree index-only scan and return the top-level plan.
+sub explain_btree_index_only_json
+{
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
+  SELECT i FROM btree_tst WHERE i = 7;
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Run EXPLAIN for a B-tree scan that exits before reading the index.
+sub explain_btree_null_json
+{
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SET plan_cache_mode = force_generic_plan;
+PREPARE btree_null_query(int) AS
+  SELECT * FROM btree_tst WHERE i = \$1;
+EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
+  EXECUTE btree_null_query(NULL);
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Run EXPLAIN with a never-executed gin index scan.
+sub explain_never_executed_gin_json
+{
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET enable_seqscan = off;
+EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
+  SELECT g.*
+  FROM btree_tst b
+  CROSS JOIN LATERAL
+    (SELECT * FROM gin_tst g WHERE g.a @> ARRAY[b.i] OFFSET 0) g
+  WHERE b.i = -1;
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Run EXPLAIN and return the top-level plan.
+sub explain_json
+{
+	my ($buffers) = @_;
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET enable_seqscan = off;
+EXPLAIN (ANALYZE, $buffers, FORMAT JSON)
+  SELECT * FROM tst WHERE i = 7;
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Run EXPLAIN for a Bloom scan that exits before reading the index.
+sub explain_bloom_null_json
+{
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET enable_seqscan = off;
+SET plan_cache_mode = force_generic_plan;
+PREPARE bloom_null_query(int) AS SELECT * FROM tst WHERE i = \$1;
+EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
+  EXECUTE bloom_null_query(NULL);
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Run EXPLAIN in a parallel worker and return the top-level plan.
+sub explain_parallel_json
+{
+	my $output = $node->safe_psql(
+		'postgres', qq{
+SET max_parallel_workers_per_gather = 1;
+SET debug_parallel_query = on;
+SET enable_seqscan = off;
+EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
+  SELECT * FROM tst WHERE i = 7;
+});
+
+	return decode_json($output)->[0]->{'Plan'};
+}
+
+# Find the first plan node of the requested type.
+sub find_plan_node
+{
+	my ($plan, $type) = @_;
+
+	return $plan if $plan->{'Node Type'} eq $type;
+
+	foreach my $child (@{ $plan->{'Plans'} // [] })
+	{
+		my $result = find_plan_node($child, $type);
+
+		return $result if defined $result;
+	}
+
+	return;
+}
+
+# Find the first never-executed plan node of the requested type.
+sub find_never_executed_plan_node
+{
+	my ($plan, $type) = @_;
+
+	return $plan
+	  if $plan->{'Node Type'} eq $type && $plan->{'Actual Loops'} == 0;
+
+	foreach my $child (@{ $plan->{'Plans'} // [] })
+	{
+		my $result = find_never_executed_plan_node($child, $type);
+
+		return $result if defined $result;
+	}
+
+	return;
+}
diff --git a/doc/src/sgml/ref/explain.sgml b/doc/src/sgml/ref/explain.sgml
index 3841f171bb3..eb28a489f48 100644
--- a/doc/src/sgml/ref/explain.sgml
+++ b/doc/src/sgml/ref/explain.sgml
@@ -207,8 +207,12 @@ ROLLBACK;
       nodes report the <glossterm
       linkend="glossary-buffer-access-strategy">buffer access
       strategies</glossterm> used by the table scan when the table access
-      method provides them.  The reported list includes every strategy
-      selected while executing the scan, including across rescans.
+      method provides them.  Executed
+      <literal>Index Scan</literal>, <literal>Index Only Scan</literal>, and
+      <literal>Bitmap Index Scan</literal> plan nodes likewise report the
+      strategies used by the index scan when the index access method provides
+      them.  The reported list includes every strategy selected while
+      executing the scan, including across rescans.
       Only strategies used by scan nodes are reported; write-side strategies
       such as <literal>bulkwrite</literal> are not shown, even when a command
       uses them internally.
diff --git a/src/backend/access/brin/brin.c b/src/backend/access/brin/brin.c
index 5059da8dce7..7c9f5021f0b 100644
--- a/src/backend/access/brin/brin.c
+++ b/src/backend/access/brin/brin.c
@@ -598,6 +598,7 @@ bringetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	pgstat_count_index_scan(idxRel);
 	if (scan->instrument)
 		scan->instrument->nsearches++;
+	InstrRecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
 
 	/*
 	 * We need to know the size of the table so that we know how long to
diff --git a/src/backend/access/gin/ginget.c b/src/backend/access/gin/ginget.c
index 2bcb32ca3d0..31595a5a12f 100644
--- a/src/backend/access/gin/ginget.c
+++ b/src/backend/access/gin/ginget.c
@@ -17,6 +17,7 @@
 #include "access/gin_private.h"
 #include "access/relscan.h"
 #include "common/pg_prng.h"
+#include "executor/instrument_node.h"
 #include "miscadmin.h"
 #include "storage/predicate.h"
 #include "utils/datum.h"
@@ -1942,6 +1943,8 @@ gingetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	if (GinIsVoidRes(scan))
 		return 0;
 
+	InstrRecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
+
 	ntids = 0;
 
 	/*
diff --git a/src/backend/access/gist/gistget.c b/src/backend/access/gist/gistget.c
index 111c310f9df..62b386aa356 100644
--- a/src/backend/access/gist/gistget.c
+++ b/src/backend/access/gist/gistget.c
@@ -641,6 +641,7 @@ gistgettuple(IndexScanDesc scan, ScanDirection dir)
 		/* Begin the scan by processing the root page */
 		GISTSearchItem fakeItem;
 
+		InstrRecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
 		pgstat_count_index_scan(scan->indexRelation);
 		if (scan->instrument)
 			scan->instrument->nsearches++;
@@ -761,6 +762,7 @@ gistgetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	if (!so->qual_ok)
 		return 0;
 
+	InstrRecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
 	pgstat_count_index_scan(scan->indexRelation);
 	if (scan->instrument)
 		scan->instrument->nsearches++;
diff --git a/src/backend/access/hash/hashsearch.c b/src/backend/access/hash/hashsearch.c
index 46eb12cc8b2..9955ad26ea2 100644
--- a/src/backend/access/hash/hashsearch.c
+++ b/src/backend/access/hash/hashsearch.c
@@ -407,6 +407,7 @@ _hash_first(IndexScanDesc scan, ScanDirection dir)
 		hashkey = _hash_datum2hashkey_type(rel, cur->sk_argument,
 										   cur->sk_subtype);
 
+	InstrRecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
 	so->hashso_sk_hash = hashkey;
 
 	buf = _hash_getbucketbuf_from_hashkey(rel, hashkey, HASH_READ, NULL);
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..e806a95c110 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -922,6 +922,8 @@ _bt_first(IndexScanDesc scan, ScanDirection dir)
 		!_bt_parallel_seize(scan, &blkno, &lastcurrblkno, true))
 		return false;
 
+	InstrRecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
+
 	/*
 	 * Initialize the scan's arrays (if any) for the current scan direction
 	 * (except when they were already set to later values as part of
diff --git a/src/backend/access/spgist/spgscan.c b/src/backend/access/spgist/spgscan.c
index 2cc5f06f5d7..5327ab57e0a 100644
--- a/src/backend/access/spgist/spgscan.c
+++ b/src/backend/access/spgist/spgscan.c
@@ -944,6 +944,9 @@ spggetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 	so->tbm = tbm;
 	so->ntids = 0;
 
+	if (so->searchNulls || so->searchNonNulls)
+		InstrRecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
+
 	spgWalk(scan->indexRelation, so, true, storeBitmap);
 
 	return so->ntids;
@@ -1065,6 +1068,9 @@ spggettuple(IndexScanDesc scan, ScanDirection dir)
 		}
 		so->iPtr = so->nPtrs = 0;
 
+		if (so->searchNulls || so->searchNonNulls)
+			InstrRecordIndexScanStrategy(scan->instrument, BAS_NORMAL);
+
 		spgWalk(scan->indexRelation, so, false, storeGettuple);
 
 		if (so->nPtrs == 0)
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 735150c859e..2c5a31c2d5b 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -140,6 +140,8 @@ static void show_hashagg_info(AggState *aggstate, ExplainState *es);
 static void show_indexscan_info(PlanState *planstate, ExplainState *es);
 static void show_tidbitmap_info(BitmapHeapScanState *planstate,
 								ExplainState *es);
+static void print_buffer_access_strategies(ExplainState *es,
+										   uint32 strategy_mask);
 static void show_scan_buffer_access_strategies(ScanState *planstate, ExplainState *es);
 static void show_scan_io_usage(ScanState *planstate,
 							   ExplainState *es);
@@ -3909,8 +3911,10 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 {
 	Plan	   *plan = planstate->plan;
 	SharedIndexScanInstrumentation *SharedInfo = NULL;
-	uint64		nsearches = 0,
-				ntabletuplefetches = 0;
+	WorkerNodeInstrumentation *worker_instrument = planstate->worker_instrument;
+	IndexScanInstrumentation stats = {0};
+	double		leader_nloops = planstate->instrument ?
+		planstate->instrument->nloops : 0;
 
 	if (!es->analyze)
 		return;
@@ -3922,7 +3926,7 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 			{
 				IndexScanState *indexstate = ((IndexScanState *) planstate);
 
-				nsearches = indexstate->iss_Instrument->nsearches;
+				stats = *indexstate->iss_Instrument;
 				SharedInfo = indexstate->iss_SharedInfo;
 				break;
 			}
@@ -3930,8 +3934,7 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 			{
 				IndexOnlyScanState *indexstate = ((IndexOnlyScanState *) planstate);
 
-				nsearches = indexstate->ioss_Instrument->nsearches;
-				ntabletuplefetches = indexstate->ioss_Instrument->ntabletuplefetches;
+				stats = *indexstate->ioss_Instrument;
 				SharedInfo = indexstate->ioss_SharedInfo;
 				break;
 			}
@@ -3939,7 +3942,7 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 			{
 				BitmapIndexScanState *indexstate = ((BitmapIndexScanState *) planstate);
 
-				nsearches = indexstate->biss_Instrument->nsearches;
+				stats = *indexstate->biss_Instrument;
 				SharedInfo = indexstate->biss_SharedInfo;
 				break;
 			}
@@ -3947,22 +3950,50 @@ show_indexscan_info(PlanState *planstate, ExplainState *es)
 			break;
 	}
 
+	if (worker_instrument)
+	{
+		/*
+		 * After parallel cleanup, the node's loop count includes worker
+		 * loops. Remove them to determine whether the leader executed the
+		 * scan.
+		 */
+		for (int i = 0; i < worker_instrument->num_workers; ++i)
+			leader_nloops -= worker_instrument->instrument[i].nloops;
+	}
+
 	/* Next get the sum of the counters set within each and every process */
 	if (SharedInfo)
 	{
 		for (int i = 0; i < SharedInfo->num_workers; ++i)
 		{
 			IndexScanInstrumentation *winstrument = &SharedInfo->winstrument[i];
+			double		worker_nloops =
+				worker_instrument && i < worker_instrument->num_workers ?
+				worker_instrument->instrument[i].nloops : 0;
+
+			stats.nsearches += winstrument->nsearches;
+			stats.ntabletuplefetches += winstrument->ntabletuplefetches;
 
-			nsearches += winstrument->nsearches;
-			ntabletuplefetches += winstrument->ntabletuplefetches;
+			if (!es->buffers || worker_nloops <= 0 || winstrument->strategy_mask == 0)
+				continue;
+
+			if (es->workers_state)
+				ExplainOpenWorker(i, es);
+
+			print_buffer_access_strategies(es, winstrument->strategy_mask);
+
+			if (es->workers_state)
+				ExplainCloseWorker(i, es);
 		}
 	}
 
+	if (es->buffers && stats.strategy_mask != 0 && leader_nloops > 0)
+		print_buffer_access_strategies(es, stats.strategy_mask);
+
 	if (nodeTag(plan) == T_IndexOnlyScan)
-		ExplainPropertyUInteger("Heap Fetches", NULL, ntabletuplefetches, es);
+		ExplainPropertyUInteger("Heap Fetches", NULL, stats.ntabletuplefetches, es);
 
-	ExplainPropertyUInteger("Index Searches", NULL, nsearches, es);
+	ExplainPropertyUInteger("Index Searches", NULL, stats.nsearches, es);
 }
 
 /*
diff --git a/src/backend/executor/nodeBitmapIndexscan.c b/src/backend/executor/nodeBitmapIndexscan.c
index 90b010f9b71..dfea3fdd84c 100644
--- a/src/backend/executor/nodeBitmapIndexscan.c
+++ b/src/backend/executor/nodeBitmapIndexscan.c
@@ -203,7 +203,7 @@ ExecEndBitmapIndexScan(BitmapIndexScanState *node)
 		 * shutdown on the workers.  On rescan it will spin up new workers
 		 * which will have a new BitmapIndexScanState and zeroed stats.
 		 */
-		winstrument->nsearches += node->biss_Instrument->nsearches;
+		CombineIndexScanInstrumentation(winstrument, node->biss_Instrument);
 		Assert(node->biss_Instrument->ntabletuplefetches == 0);
 	}
 
@@ -277,7 +277,11 @@ ExecInitBitmapIndexScan(BitmapIndexScan *node, EState *estate, int eflags)
 
 	/* Set up instrumentation of bitmap index scans if requested */
 	if (estate->es_instrument)
+	{
 		indexstate->biss_Instrument = palloc0_object(IndexScanInstrumentation);
+		indexstate->biss_Instrument->need_strategy =
+			(estate->es_instrument & INSTRUMENT_BUFFERS) != 0;
+	}
 
 	/* Open the index relation. */
 	lockmode = exec_rt_fetch(node->scan.scanrelid, estate)->rellockmode;
diff --git a/src/backend/executor/nodeIndexonlyscan.c b/src/backend/executor/nodeIndexonlyscan.c
index b1009027ac8..14ce758c00f 100644
--- a/src/backend/executor/nodeIndexonlyscan.c
+++ b/src/backend/executor/nodeIndexonlyscan.c
@@ -260,8 +260,7 @@ ExecEndIndexOnlyScan(IndexOnlyScanState *node)
 		 * shutdown on the workers.  On rescan it will spin up new workers
 		 * which will have a new IndexOnlyScanState and zeroed stats.
 		 */
-		winstrument->nsearches += node->ioss_Instrument->nsearches;
-		winstrument->ntabletuplefetches += node->ioss_Instrument->ntabletuplefetches;
+		CombineIndexScanInstrumentation(winstrument, node->ioss_Instrument);
 	}
 
 	/*
@@ -426,7 +425,11 @@ ExecInitIndexOnlyScan(IndexOnlyScan *node, EState *estate, int eflags)
 
 	/* Set up instrumentation of index-only scans if requested */
 	if (estate->es_instrument)
+	{
 		indexstate->ioss_Instrument = palloc0_object(IndexScanInstrumentation);
+		indexstate->ioss_Instrument->need_strategy =
+			(estate->es_instrument & INSTRUMENT_BUFFERS) != 0;
+	}
 
 	/* Open the index relation. */
 	lockmode = exec_rt_fetch(node->scan.scanrelid, estate)->rellockmode;
diff --git a/src/backend/executor/nodeIndexscan.c b/src/backend/executor/nodeIndexscan.c
index 129d005f187..83262d4ea6e 100644
--- a/src/backend/executor/nodeIndexscan.c
+++ b/src/backend/executor/nodeIndexscan.c
@@ -819,7 +819,7 @@ ExecEndIndexScan(IndexScanState *node)
 		 * shutdown on the workers.  On rescan it will spin up new workers
 		 * which will have a new IndexOnlyScanState and zeroed stats.
 		 */
-		winstrument->nsearches += node->iss_Instrument->nsearches;
+		CombineIndexScanInstrumentation(winstrument, node->iss_Instrument);
 		Assert(node->iss_Instrument->ntabletuplefetches == 0);
 	}
 
@@ -983,7 +983,11 @@ ExecInitIndexScan(IndexScan *node, EState *estate, int eflags)
 
 	/* Set up instrumentation of index scans if requested */
 	if (estate->es_instrument)
+	{
 		indexstate->iss_Instrument = palloc0_object(IndexScanInstrumentation);
+		indexstate->iss_Instrument->need_strategy =
+			(estate->es_instrument & INSTRUMENT_BUFFERS) != 0;
+	}
 
 	/* Open the index relation. */
 	lockmode = exec_rt_fetch(node->scan.scanrelid, estate)->rellockmode;
diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
index 77abccfe3e1..5b49edc251b 100644
--- a/src/include/executor/instrument_node.h
+++ b/src/include/executor/instrument_node.h
@@ -150,8 +150,45 @@ typedef struct IndexScanInstrumentation
 
 	/* Table tuples fetched count (incremented during index-only scans) */
 	uint64		ntabletuplefetches;
+
+	/* Whether the index AM should record its buffer access strategy */
+	bool		need_strategy;
+
+	/* Buffer access strategies reported by the index AM */
+	uint32		strategy_mask;
 } IndexScanInstrumentation;
 
+/*
+ * Record a buffer access strategy selected by an index AM.
+ *
+ * Record strategies only when requested by the executor and preserve every
+ * strategy observed across rescans.
+ */
+static inline void
+InstrRecordIndexScanStrategy(IndexScanInstrumentation *instrument,
+							 BufferAccessStrategyType strategy)
+{
+	if (!instrument)
+		return;
+
+	if (instrument->need_strategy)
+		instrument->strategy_mask |= BufferAccessStrategyBit(strategy);
+}
+
+/*
+ * Combine index scan instrumentation from 'src' into 'dst'.
+ *
+ * Counter values are added and observed strategies are preserved.
+ */
+static inline void
+CombineIndexScanInstrumentation(IndexScanInstrumentation *dst,
+								IndexScanInstrumentation *src)
+{
+	dst->nsearches += src->nsearches;
+	dst->ntabletuplefetches += src->ntabletuplefetches;
+	dst->strategy_mask |= src->strategy_mask;
+}
+
 /*
  * Shared memory container for per-worker information
  */
-- 
2.34.1

