From d89ae2928dad0b9081f6aa1cf839b59a8f374e5e Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Tue, 31 Mar 2026 13:50:57 +0200
Subject: [PATCH v8 4/5] show prefetch stats for SeqScan

- enable show_scan_io_usage for SeqScan

- add infrastructure to allocate/collect instrumentation from parallel workers
---
 .../postgres_fdw/expected/postgres_fdw.out    |   8 +-
 contrib/postgres_fdw/sql/postgres_fdw.sql     |   8 +-
 src/backend/commands/explain.c                |  32 ++-
 src/backend/executor/execParallel.c           |  19 +-
 src/backend/executor/nodeSeqscan.c            | 182 ++++++++++++++++--
 src/include/access/relscan.h                  |   1 +
 src/include/executor/instrument_node.h        |  15 +-
 src/include/executor/nodeSeqscan.h            |   1 +
 src/include/nodes/execnodes.h                 |   1 +
 src/test/isolation/expected/merge-update.out  |   2 +-
 src/test/isolation/specs/merge-update.spec    |   5 +-
 src/test/regress/expected/explain.out         |  26 ++-
 src/test/regress/expected/partition_prune.out |  34 ++--
 src/test/regress/sql/explain.sql              |  12 +-
 src/test/regress/sql/partition_prune.sql      |  34 ++--
 15 files changed, 298 insertions(+), 82 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index ac34a1acacb..295c94934b0 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -11980,7 +11980,7 @@ SELECT * FROM local_tbl, async_pt WHERE local_tbl.a = async_pt.a AND local_tbl.c
                Filter: (async_pt_3.a = local_tbl.a)
 (15 rows)
 
-EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF)
+EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF, IO OFF)
 SELECT * FROM local_tbl, async_pt WHERE local_tbl.a = async_pt.a AND local_tbl.c = 'bar';
                                     QUERY PLAN                                    
 ----------------------------------------------------------------------------------
@@ -12226,7 +12226,7 @@ SELECT * FROM local_tbl t1 LEFT JOIN (SELECT *, (SELECT count(*) FROM async_pt W
                Remote SQL: SELECT a, b, c FROM public.base_tbl2 WHERE ((a < 3000))
 (20 rows)
 
-EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF)
+EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF, IO OFF)
 SELECT * FROM local_tbl t1 LEFT JOIN (SELECT *, (SELECT count(*) FROM async_pt WHERE a < 3000) FROM async_pt WHERE a < 3000) t2 ON t1.a = t2.a;
                                          QUERY PLAN                                         
 --------------------------------------------------------------------------------------------
@@ -12270,7 +12270,7 @@ SELECT * FROM async_pt t1 WHERE t1.b === 505 LIMIT 1;
                Filter: (t1_3.b === 505)
 (14 rows)
 
-EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF)
+EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF, IO OFF)
 SELECT * FROM async_pt t1 WHERE t1.b === 505 LIMIT 1;
                                  QUERY PLAN                                 
 ----------------------------------------------------------------------------
@@ -12430,7 +12430,7 @@ DELETE FROM async_pt WHERE b = 0 RETURNING *;
 DELETE FROM async_p1;
 DELETE FROM async_p2;
 DELETE FROM async_p3;
-EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF)
+EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF, IO OFF)
 SELECT * FROM async_pt;
                                  QUERY PLAN                                 
 ----------------------------------------------------------------------------
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 0e218b29a29..f5f1bbe1651 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -4093,7 +4093,7 @@ ALTER FOREIGN TABLE async_p2 OPTIONS (use_remote_estimate 'true');
 
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT * FROM local_tbl, async_pt WHERE local_tbl.a = async_pt.a AND local_tbl.c = 'bar';
-EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF)
+EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF, IO OFF)
 SELECT * FROM local_tbl, async_pt WHERE local_tbl.a = async_pt.a AND local_tbl.c = 'bar';
 SELECT * FROM local_tbl, async_pt WHERE local_tbl.a = async_pt.a AND local_tbl.c = 'bar';
 
@@ -4168,13 +4168,13 @@ ANALYZE local_tbl;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT * FROM local_tbl t1 LEFT JOIN (SELECT *, (SELECT count(*) FROM async_pt WHERE a < 3000) FROM async_pt WHERE a < 3000) t2 ON t1.a = t2.a;
-EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF)
+EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF, IO OFF)
 SELECT * FROM local_tbl t1 LEFT JOIN (SELECT *, (SELECT count(*) FROM async_pt WHERE a < 3000) FROM async_pt WHERE a < 3000) t2 ON t1.a = t2.a;
 SELECT * FROM local_tbl t1 LEFT JOIN (SELECT *, (SELECT count(*) FROM async_pt WHERE a < 3000) FROM async_pt WHERE a < 3000) t2 ON t1.a = t2.a;
 
 EXPLAIN (VERBOSE, COSTS OFF)
 SELECT * FROM async_pt t1 WHERE t1.b === 505 LIMIT 1;
-EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF)
+EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF, IO OFF)
 SELECT * FROM async_pt t1 WHERE t1.b === 505 LIMIT 1;
 SELECT * FROM async_pt t1 WHERE t1.b === 505 LIMIT 1;
 
@@ -4226,7 +4226,7 @@ DELETE FROM async_p1;
 DELETE FROM async_p2;
 DELETE FROM async_p3;
 
-EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF)
+EXPLAIN (ANALYZE, COSTS OFF, SUMMARY OFF, TIMING OFF, BUFFERS OFF, IO OFF)
 SELECT * FROM async_pt;
 
 -- Clean up
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index fdcb7f30fe9..38403576d42 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -4047,7 +4047,7 @@ static void
 show_scan_io_usage(ScanState *planstate, ExplainState *es)
 {
 	Plan	   *plan = planstate->ps.plan;
-	IOStats		stats;
+	IOStats		stats = {0};
 
 	if (!es->io)
 		return;
@@ -4066,12 +4066,34 @@ show_scan_io_usage(ScanState *planstate, ExplainState *es)
 		stats = planstate->ss_currentScanDesc->rs_instrument->io;
 	}
 
-	/*
-	 * Initialize counters with stats from the local process first, then
-	 * accumulate data from parallel workers.
-	 */
+	/* accumulate data from parallel workers */
 	switch (nodeTag(plan))
 	{
+		case T_SeqScan:
+			{
+				SharedSeqScanInstrumentation *sinstrument
+					= ((SeqScanState *) planstate)->sinstrument;
+
+				/* get the sum of the counters set within each and every process */
+				if (sinstrument)
+				{
+					for (int i = 0; i < sinstrument->num_workers; ++i)
+					{
+						SeqScanInstrumentation *winstrument = &sinstrument->sinstrument[i];
+
+						AccumulateIOStats(&stats, &winstrument->stats.io);
+
+						if (!es->workers_state)
+							continue;
+
+						ExplainOpenWorker(i, es);
+						print_io_usage(es, &winstrument->stats.io);
+						ExplainCloseWorker(i, es);
+					}
+				}
+
+				break;
+			}
 		case T_BitmapHeapScan:
 			{
 				SharedBitmapHeapInstrumentation *sinstrument
diff --git a/src/backend/executor/execParallel.c b/src/backend/executor/execParallel.c
index ce377a774f8..9862c922afa 100644
--- a/src/backend/executor/execParallel.c
+++ b/src/backend/executor/execParallel.c
@@ -250,9 +250,9 @@ ExecParallelEstimate(PlanState *planstate, ExecParallelEstimateContext *e)
 	switch (nodeTag(planstate))
 	{
 		case T_SeqScanState:
-			if (planstate->plan->parallel_aware)
-				ExecSeqScanEstimate((SeqScanState *) planstate,
-									e->pcxt);
+			/* even when not parallel-aware, for EXPLAIN ANALYZE */
+			ExecSeqScanEstimate((SeqScanState *) planstate,
+								e->pcxt);
 			break;
 		case T_IndexScanState:
 			/* even when not parallel-aware, for EXPLAIN ANALYZE */
@@ -484,9 +484,9 @@ ExecParallelInitializeDSM(PlanState *planstate,
 	switch (nodeTag(planstate))
 	{
 		case T_SeqScanState:
-			if (planstate->plan->parallel_aware)
-				ExecSeqScanInitializeDSM((SeqScanState *) planstate,
-										 d->pcxt);
+			/* even when not parallel-aware, for EXPLAIN ANALYZE */
+			ExecSeqScanInitializeDSM((SeqScanState *) planstate,
+									 d->pcxt);
 			break;
 		case T_IndexScanState:
 			/* even when not parallel-aware, for EXPLAIN ANALYZE */
@@ -1125,6 +1125,9 @@ ExecParallelRetrieveInstrumentation(PlanState *planstate,
 		case T_BitmapHeapScanState:
 			ExecBitmapHeapRetrieveInstrumentation((BitmapHeapScanState *) planstate);
 			break;
+		case T_SeqScanState:
+			ExecSeqScanRetrieveInstrumentation((SeqScanState *) planstate);
+			break;
 		default:
 			break;
 	}
@@ -1363,8 +1366,8 @@ ExecParallelInitializeWorker(PlanState *planstate, ParallelWorkerContext *pwcxt)
 	switch (nodeTag(planstate))
 	{
 		case T_SeqScanState:
-			if (planstate->plan->parallel_aware)
-				ExecSeqScanInitializeWorker((SeqScanState *) planstate, pwcxt);
+			/* even when not parallel-aware, for EXPLAIN ANALYZE */
+			ExecSeqScanInitializeWorker((SeqScanState *) planstate, pwcxt);
 			break;
 		case T_IndexScanState:
 			/* even when not parallel-aware, for EXPLAIN ANALYZE */
diff --git a/src/backend/executor/nodeSeqscan.c b/src/backend/executor/nodeSeqscan.c
index 04803b0e37d..bdc91a279e0 100644
--- a/src/backend/executor/nodeSeqscan.c
+++ b/src/backend/executor/nodeSeqscan.c
@@ -65,6 +65,14 @@ SeqNext(SeqScanState *node)
 
 	if (scandesc == NULL)
 	{
+		uint32	flags = SO_NONE;
+
+		if (ScanRelIsReadOnly(&node->ss))
+			flags |= SO_HINT_REL_READ_ONLY;
+
+		if (estate->es_instrument)
+			flags |= SO_SCAN_INSTRUMENT;
+
 		/*
 		 * We reach here if the scan is not parallel, or if we're serially
 		 * executing a scan that was planned to be parallel.
@@ -72,8 +80,7 @@ SeqNext(SeqScanState *node)
 		scandesc = table_beginscan(node->ss.ss_currentRelation,
 								   estate->es_snapshot,
 								   0, NULL,
-								   ScanRelIsReadOnly(&node->ss) ?
-								   SO_HINT_REL_READ_ONLY : SO_NONE);
+								   flags);
 		node->ss.ss_currentScanDesc = scandesc;
 	}
 
@@ -297,6 +304,35 @@ ExecEndSeqScan(SeqScanState *node)
 {
 	TableScanDesc scanDesc;
 
+	/*
+	 * When ending a parallel worker, copy the statistics gathered by the
+	 * worker back into shared memory so that it can be picked up by the main
+	 * process to report in EXPLAIN ANALYZE.
+	 */
+	if (node->sinstrument != NULL && IsParallelWorker())
+	{
+		SeqScanInstrumentation *si;
+
+		Assert(ParallelWorkerNumber < node->sinstrument->num_workers);
+		si = &node->sinstrument->sinstrument[ParallelWorkerNumber];
+
+		/*
+		 * Here we accumulate the stats rather than performing memcpy on
+		 * node->stats into si.  When a Gather/GatherMerge node finishes it
+		 * will perform planner shutdown on the workers.  On rescan it will
+		 * spin up new workers which will have a new SeqScanState and
+		 * zeroed stats.
+		 */
+
+		/* collect prefetch info for this process from the read_stream */
+		if (node->ss.ss_currentScanDesc &&
+			node->ss.ss_currentScanDesc->rs_instrument)
+		{
+			AccumulateIOStats(&si->stats.io,
+							  &node->ss.ss_currentScanDesc->rs_instrument->io);
+		}
+	}
+
 	/*
 	 * get information from node
 	 */
@@ -351,10 +387,31 @@ ExecSeqScanEstimate(SeqScanState *node,
 					ParallelContext *pcxt)
 {
 	EState	   *estate = node->ss.ps.state;
+	bool		instrument = node->ss.ps.instrument != NULL;
+	bool		parallel_aware = node->ss.ps.plan->parallel_aware;
+	Size		size;
+
+	if (!instrument && !parallel_aware)
+	{
+		/* No DSM required by the scan */
+		return;
+	}
+
+	size = table_parallelscan_estimate(node->ss.ss_currentRelation,
+									   estate->es_snapshot);
+	node->pscan_len = size;
 
-	node->pscan_len = table_parallelscan_estimate(node->ss.ss_currentRelation,
-												  estate->es_snapshot);
-	shm_toc_estimate_chunk(&pcxt->estimator, node->pscan_len);
+	/* make sure the instrumentation is properly aligned */
+	size = MAXALIGN(size);
+
+	/* account for instrumentation, if required */
+	if (instrument && pcxt->nworkers > 0)
+	{
+		size = add_size(size, offsetof(SharedSeqScanInstrumentation, sinstrument));
+		size = add_size(size, mul_size(pcxt->nworkers, sizeof(SeqScanInstrumentation)));
+	}
+
+	shm_toc_estimate_chunk(&pcxt->estimator, size);
 	shm_toc_estimate_keys(&pcxt->estimator, 1);
 }
 
@@ -370,17 +427,66 @@ ExecSeqScanInitializeDSM(SeqScanState *node,
 {
 	EState	   *estate = node->ss.ps.state;
 	ParallelTableScanDesc pscan;
+	SharedSeqScanInstrumentation *sinstrument = NULL;
+	bool		instrument = node->ss.ps.instrument != NULL;
+	bool		parallel_aware = node->ss.ps.plan->parallel_aware;
+	Size		size;
+	uint32		flags = SO_NONE;
+
+	if (!instrument && !parallel_aware)
+	{
+		/* No DSM required by the scan */
+		return;
+	}
+
+	/* Recalculate the size. This needs to match ExecSeqScanEstimate. */
+	size = MAXALIGN(node->pscan_len);
+	if (instrument && pcxt->nworkers > 0)
+	{
+		size = add_size(size, offsetof(SharedSeqScanInstrumentation, sinstrument));
+		size = add_size(size, mul_size(pcxt->nworkers, sizeof(SeqScanInstrumentation)));
+	}
+
+	pscan = shm_toc_allocate(pcxt->toc, size);
+	pscan->phs_offset_ins = MAXALIGN(node->pscan_len);
 
-	pscan = shm_toc_allocate(pcxt->toc, node->pscan_len);
 	table_parallelscan_initialize(node->ss.ss_currentRelation,
 								  pscan,
 								  estate->es_snapshot);
 	shm_toc_insert(pcxt->toc, node->ss.ps.plan->plan_node_id, pscan);
 
+	/* initialize the shared instrumentation (with correct alignment) */
+	if (instrument && pcxt->nworkers > 0)
+	{
+		char *ptr = (char *) pscan;
+
+		ptr += MAXALIGN(node->pscan_len);
+
+		sinstrument = (SharedSeqScanInstrumentation *) ptr;
+
+		sinstrument->num_workers = pcxt->nworkers;
+
+		/* ensure any unfilled slots will contain zeroes */
+		memset(sinstrument->sinstrument, 0,
+			   pcxt->nworkers * sizeof(SeqScanInstrumentation));
+
+		node->sinstrument = sinstrument;
+	}
+
+	if (!parallel_aware)
+	{
+		/* Only here to set up worker node's shared instrumentation */
+		return;
+	}
+
+	if (ScanRelIsReadOnly(&node->ss))
+		flags |= SO_HINT_REL_READ_ONLY;
+
+	if (estate->es_instrument)
+		flags |= SO_SCAN_INSTRUMENT;
+
 	node->ss.ss_currentScanDesc =
-		table_beginscan_parallel(node->ss.ss_currentRelation, pscan,
-								 ScanRelIsReadOnly(&node->ss) ?
-								 SO_HINT_REL_READ_ONLY : SO_NONE);
+		table_beginscan_parallel(node->ss.ss_currentRelation, pscan, flags);
 }
 
 /* ----------------------------------------------------------------
@@ -410,10 +516,62 @@ ExecSeqScanInitializeWorker(SeqScanState *node,
 							ParallelWorkerContext *pwcxt)
 {
 	ParallelTableScanDesc pscan;
+	EState	   *estate = node->ss.ps.state;
+	bool		instrument = node->ss.ps.instrument != NULL;
+	bool		parallel_aware = node->ss.ps.plan->parallel_aware;
+	uint32		flags = SO_NONE;
+
+	if (!instrument && !parallel_aware)
+	{
+		/* No DSM required by the scan */
+		return;
+	}
 
 	pscan = shm_toc_lookup(pwcxt->toc, node->ss.ps.plan->plan_node_id, false);
+
+	/* set pointer to the shared instrumentation */
+	if (instrument)
+	{
+		char *ptr = (char *) pscan;
+		ptr += pscan->phs_offset_ins;
+
+		node->sinstrument = (SharedSeqScanInstrumentation *) ptr;
+	}
+
+	if (!parallel_aware)
+	{
+		/* Only here to set up worker node's SharedInfo */
+		return;
+	}
+
+	if (ScanRelIsReadOnly(&node->ss))
+		flags |= SO_HINT_REL_READ_ONLY;
+
+	if (estate->es_instrument)
+		flags |= SO_SCAN_INSTRUMENT;
+
 	node->ss.ss_currentScanDesc =
-		table_beginscan_parallel(node->ss.ss_currentRelation, pscan,
-								 ScanRelIsReadOnly(&node->ss) ?
-								 SO_HINT_REL_READ_ONLY : SO_NONE);
+		table_beginscan_parallel(node->ss.ss_currentRelation, pscan, flags);
+}
+
+/* ----------------------------------------------------------------
+ *		ExecSeqScanRetrieveInstrumentation
+ *
+ *		Transfer seq scan statistics from DSM to private memory.
+ * ----------------------------------------------------------------
+ */
+void
+ExecSeqScanRetrieveInstrumentation(SeqScanState *node)
+{
+	SharedSeqScanInstrumentation *sinstrument = node->sinstrument;
+	Size		size;
+
+	if (sinstrument == NULL)
+		return;
+
+	size = offsetof(SharedSeqScanInstrumentation, sinstrument)
+		+ sinstrument->num_workers * sizeof(SeqScanInstrumentation);
+
+	node->sinstrument = palloc(size);
+	memcpy(node->sinstrument, sinstrument, size);
 }
diff --git a/src/include/access/relscan.h b/src/include/access/relscan.h
index a9c334e633d..46cf957ee0a 100644
--- a/src/include/access/relscan.h
+++ b/src/include/access/relscan.h
@@ -89,6 +89,7 @@ typedef struct ParallelTableScanDescData
 	bool		phs_syncscan;	/* report location to syncscan logic? */
 	bool		phs_snapshot_any;	/* SnapshotAny, not phs_snapshot_data? */
 	Size		phs_snapshot_off;	/* data for snapshot */
+	Size		phs_offset_ins;		/* length of the scan descriptor */
 } ParallelTableScanDescData;
 typedef struct ParallelTableScanDescData *ParallelTableScanDesc;
 
diff --git a/src/include/executor/instrument_node.h b/src/include/executor/instrument_node.h
index 6d1554fbc46..dba3ad7f599 100644
--- a/src/include/executor/instrument_node.h
+++ b/src/include/executor/instrument_node.h
@@ -89,9 +89,22 @@ AccumulateIOStats(IOStats *dst, IOStats *src)
 }
 
 /* ---------------------
- *	Instrumentation information for indexscans (amgettuple and amgetbitmap)
+ *	Instrumentation information for sequential scans
  * ---------------------
  */
+typedef struct SeqScanInstrumentation
+{
+	TableScanInstrumentation		stats;
+} SeqScanInstrumentation;
+
+/*
+ * Shared memory container for per-worker information
+ */
+typedef struct SharedSeqScanInstrumentation
+{
+	int			num_workers;
+	SeqScanInstrumentation sinstrument[FLEXIBLE_ARRAY_MEMBER];
+} SharedSeqScanInstrumentation;
 
 typedef struct IndexScanInstrumentation
 {
diff --git a/src/include/executor/nodeSeqscan.h b/src/include/executor/nodeSeqscan.h
index 7a1490596fb..e2122bfffe3 100644
--- a/src/include/executor/nodeSeqscan.h
+++ b/src/include/executor/nodeSeqscan.h
@@ -27,5 +27,6 @@ extern void ExecSeqScanInitializeDSM(SeqScanState *node, ParallelContext *pcxt);
 extern void ExecSeqScanReInitializeDSM(SeqScanState *node, ParallelContext *pcxt);
 extern void ExecSeqScanInitializeWorker(SeqScanState *node,
 										ParallelWorkerContext *pwcxt);
+extern void ExecSeqScanRetrieveInstrumentation(SeqScanState *node);
 
 #endif							/* NODESEQSCAN_H */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 090cfccf65f..fd0642608a8 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1666,6 +1666,7 @@ typedef struct SeqScanState
 {
 	ScanState	ss;				/* its first field is NodeTag */
 	Size		pscan_len;		/* size of parallel heap scan descriptor */
+	SharedSeqScanInstrumentation *sinstrument;	/* statistics for workers */
 } SeqScanState;
 
 /* ----------------
diff --git a/src/test/isolation/expected/merge-update.out b/src/test/isolation/expected/merge-update.out
index 821565b4303..dd9c67c4dc1 100644
--- a/src/test/isolation/expected/merge-update.out
+++ b/src/test/isolation/expected/merge-update.out
@@ -358,7 +358,7 @@ step pa_merge1:
 
 step explain_pa_merge2a: 
   SELECT explain_filter($$
-  EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
+  EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF, IO OFF)
   MERGE INTO pa_target t
   USING (SELECT 1 as key, 'pa_merge2a' as val) s
   ON s.key = t.key
diff --git a/src/test/isolation/specs/merge-update.spec b/src/test/isolation/specs/merge-update.spec
index b902779edd6..cf387eccefc 100644
--- a/src/test/isolation/specs/merge-update.spec
+++ b/src/test/isolation/specs/merge-update.spec
@@ -33,6 +33,9 @@ setup
     FOR ln IN EXECUTE $1 LOOP
       -- Ignore hash memory usage because it varies depending on the system
       CONTINUE WHEN (ln ~ 'Memory Usage');
+      -- Ignore prefetch and I/O because it varies depending on the system
+      CONTINUE WHEN (ln ~ 'Prefetch');
+      CONTINUE WHEN (ln ~ 'I/O');
       RETURN NEXT ln;
     END LOOP;
   END;
@@ -165,7 +168,7 @@ step "pa_merge2a"
 step "explain_pa_merge2a"
 {
   SELECT explain_filter($$
-  EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
+  EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF, IO OFF)
   MERGE INTO pa_target t
   USING (SELECT 1 as key, 'pa_merge2a' as val) s
   ON s.key = t.key
diff --git a/src/test/regress/expected/explain.out b/src/test/regress/expected/explain.out
index d7754c6f2cb..3e504911db4 100644
--- a/src/test/regress/expected/explain.out
+++ b/src/test/regress/expected/explain.out
@@ -85,7 +85,7 @@ select explain_filter('explain (analyze, buffers off, io off, verbose) select *
  Execution Time: N.N ms
 (4 rows)
 
-select explain_filter('explain (analyze, buffers, format text) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers, io off, format text) select * from int8_tbl i8');
                                          explain_filter                                          
 -------------------------------------------------------------------------------------------------
  Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
@@ -119,6 +119,13 @@ explain_filter
       <Actual-Rows>N.N</Actual-Rows>
       <Actual-Loops>N</Actual-Loops>
       <Disabled>false</Disabled>
+      <Average-Prefetch-Distance>N.N</Average-Prefetch-Distance>
+      <Max-Prefetch-Distance>N</Max-Prefetch-Distance>
+      <Prefetch-Capacity>N</Prefetch-Capacity>
+      <I-O-Count>N</I-O-Count>
+      <I-O-Waits>N</I-O-Waits>
+      <Average-I-O-Size>N.N</Average-I-O-Size>
+      <Average-I-Os-In-Progress>N.N</Average-I-Os-In-Progress>
       <Shared-Hit-Blocks>N</Shared-Hit-Blocks>
       <Shared-Read-Blocks>N</Shared-Read-Blocks>
       <Shared-Dirtied-Blocks>N</Shared-Dirtied-Blocks>
@@ -166,6 +173,13 @@ explain_filter
     Actual Rows: N.N
     Actual Loops: N
     Disabled: false
+    Average Prefetch Distance: N.N
+    Max Prefetch Distance: N
+    Prefetch Capacity: N
+    I/O Count: N
+    I/O Waits: N
+    Average I/O Size: N.N
+    Average I/Os In Progress: N.N
     Shared Hit Blocks: N
     Shared Read Blocks: N
     Shared Dirtied Blocks: N
@@ -286,7 +300,7 @@ select explain_filter('explain verbose select sum(unique1) over w1, sum(unique2)
 -- Check output including I/O timings.  These fields are conditional
 -- but always set in JSON format, so check them only in this case.
 set track_io_timing = on;
-select explain_filter('explain (analyze, buffers, format json) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers, io off, format json) select * from int8_tbl i8');
            explain_filter            
 -------------------------------------
  [                                  +
@@ -421,7 +435,7 @@ select explain_filter('explain (memory, summary, format yaml) select * from int8
    Planning Time: N.N
 (1 row)
 
-select explain_filter('explain (memory, analyze, format json) select * from int8_tbl i8');
+select explain_filter('explain (memory, analyze, io off, format json) select * from int8_tbl i8');
            explain_filter           
 ------------------------------------
  [                                 +
@@ -524,7 +538,7 @@ set parallel_tuple_cost=0;
 set min_parallel_table_scan_size=0;
 set max_parallel_workers_per_gather=4;
 select jsonb_pretty(
-  explain_filter_to_json('explain (analyze, verbose, buffers, format json)
+  explain_filter_to_json('explain (analyze, verbose, buffers, io off, format json)
                          select * from tenk1 order by tenthous')
   -- remove "Workers" node of the Seq Scan plan node
   #- '{0,Plan,Plans,0,Plans,0,Workers}'
@@ -749,7 +763,7 @@ select explain_filter('explain (analyze,buffers off,io off,serialize) select * f
  Execution Time: N.N ms
 (4 rows)
 
-select explain_filter('explain (analyze,serialize text,buffers,timing off) select * from int8_tbl i8');
+select explain_filter('explain (analyze,serialize text,buffers,io off,timing off) select * from int8_tbl i8');
                                   explain_filter                                   
 -----------------------------------------------------------------------------------
  Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual rows=N.N loops=N)
@@ -758,7 +772,7 @@ select explain_filter('explain (analyze,serialize text,buffers,timing off) selec
  Execution Time: N.N ms
 (4 rows)
 
-select explain_filter('explain (analyze,serialize binary,buffers,timing) select * from int8_tbl i8');
+select explain_filter('explain (analyze,serialize binary,buffers,io off,timing) select * from int8_tbl i8');
                                          explain_filter                                          
 -------------------------------------------------------------------------------------------------
  Seq Scan on int8_tbl i8  (cost=N.N..N.N rows=N width=N) (actual time=N.N..N.N rows=N.N loops=N)
diff --git a/src/test/regress/expected/partition_prune.out b/src/test/regress/expected/partition_prune.out
index 1012be91689..6bd15f26aec 100644
--- a/src/test/regress/expected/partition_prune.out
+++ b/src/test/regress/expected/partition_prune.out
@@ -2315,7 +2315,7 @@ begin;
 -- Test run-time pruning using stable functions
 create function list_part_fn(int) returns int as $$ begin return $1; end;$$ language plpgsql stable;
 -- Ensure pruning works using a stable function containing no Vars
-explain (analyze, costs off, summary off, timing off, buffers off) select * from list_part where a = list_part_fn(1);
+explain (analyze, costs off, summary off, timing off, buffers off, io off) select * from list_part where a = list_part_fn(1);
                              QUERY PLAN                              
 ---------------------------------------------------------------------
  Append (actual rows=1.00 loops=1)
@@ -2325,7 +2325,7 @@ explain (analyze, costs off, summary off, timing off, buffers off) select * from
 (4 rows)
 
 -- Ensure pruning does not take place when the function has a Var parameter
-explain (analyze, costs off, summary off, timing off, buffers off) select * from list_part where a = list_part_fn(a);
+explain (analyze, costs off, summary off, timing off, buffers off, io off) select * from list_part where a = list_part_fn(a);
                              QUERY PLAN                              
 ---------------------------------------------------------------------
  Append (actual rows=4.00 loops=1)
@@ -2340,7 +2340,7 @@ explain (analyze, costs off, summary off, timing off, buffers off) select * from
 (9 rows)
 
 -- Ensure pruning does not take place when the expression contains a Var.
-explain (analyze, costs off, summary off, timing off, buffers off) select * from list_part where a = list_part_fn(1) + a;
+explain (analyze, costs off, summary off, timing off, buffers off, io off) select * from list_part where a = list_part_fn(1) + a;
                              QUERY PLAN                              
 ---------------------------------------------------------------------
  Append (actual rows=0.00 loops=1)
@@ -2376,7 +2376,7 @@ declare
     ln text;
 begin
     for ln in
-        execute format('explain (analyze, costs off, summary off, timing off, buffers off) %s',
+        execute format('explain (analyze, costs off, summary off, timing off, buffers off, io off) %s',
             $1)
     loop
         ln := regexp_replace(ln, 'Workers Launched: \d+', 'Workers Launched: N');
@@ -2687,7 +2687,7 @@ reset parallel_tuple_cost;
 reset min_parallel_table_scan_size;
 reset max_parallel_workers_per_gather;
 -- Test run-time partition pruning with an initplan
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from ab where a = (select max(a) from lprt_a) and b = (select max(a)-1 from lprt_a);
                                  QUERY PLAN                                 
 ----------------------------------------------------------------------------
@@ -2864,7 +2864,7 @@ union all
 	select tableoid::regclass,a,b from ab
 ) ab where a = $1 and b = (select -10);
 -- Ensure the xy_1 subplan is not pruned.
-explain (analyze, costs off, summary off, timing off, buffers off) execute ab_q6(1);
+explain (analyze, costs off, summary off, timing off, buffers off, io off) execute ab_q6(1);
                          QUERY PLAN                          
 -------------------------------------------------------------
  Append (actual rows=0.00 loops=1)
@@ -3019,7 +3019,7 @@ create index tprt6_idx on tprt_6 (col1);
 insert into tprt values (10), (20), (501), (502), (505), (1001), (4500);
 set enable_hashjoin = off;
 set enable_mergejoin = off;
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 join tprt on tbl1.col1 > tprt.col1;
                                  QUERY PLAN                                  
 -----------------------------------------------------------------------------
@@ -3046,7 +3046,7 @@ select * from tbl1 join tprt on tbl1.col1 > tprt.col1;
                Index Searches: 0
 (21 rows)
 
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
                                  QUERY PLAN                                  
 -----------------------------------------------------------------------------
@@ -3097,7 +3097,7 @@ order by tbl1.col1, tprt.col1;
 
 -- Multiple partitions
 insert into tbl1 values (1001), (1010), (1011);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 inner join tprt on tbl1.col1 > tprt.col1;
                                  QUERY PLAN                                  
 -----------------------------------------------------------------------------
@@ -3124,7 +3124,7 @@ select * from tbl1 inner join tprt on tbl1.col1 > tprt.col1;
                Index Searches: 0
 (21 rows)
 
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 inner join tprt on tbl1.col1 = tprt.col1;
                                  QUERY PLAN                                  
 -----------------------------------------------------------------------------
@@ -3194,7 +3194,7 @@ order by tbl1.col1, tprt.col1;
 -- Last partition
 delete from tbl1;
 insert into tbl1 values (4400);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 join tprt on tbl1.col1 < tprt.col1;
                                  QUERY PLAN                                  
 -----------------------------------------------------------------------------
@@ -3232,7 +3232,7 @@ order by tbl1.col1, tprt.col1;
 -- No matching partition
 delete from tbl1;
 insert into tbl1 values (10000);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
                             QUERY PLAN                             
 -------------------------------------------------------------------
@@ -3478,7 +3478,7 @@ create table mc3p1 partition of mc3p
 create table mc3p2 partition of mc3p
   for values from (2, minvalue, minvalue) to (3, maxvalue, maxvalue);
 insert into mc3p values (0, 1, 1), (1, 1, 1), (2, 1, 1);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from mc3p where a < 3 and abs(b) = 1;
                         QUERY PLAN                         
 -----------------------------------------------------------
@@ -3498,7 +3498,7 @@ select * from mc3p where a < 3 and abs(b) = 1;
 --
 prepare ps1 as
   select * from mc3p where a = $1 and abs(b) < (select 3);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 execute ps1(1);
                             QUERY PLAN                            
 ------------------------------------------------------------------
@@ -3513,7 +3513,7 @@ execute ps1(1);
 deallocate ps1;
 prepare ps2 as
   select * from mc3p where a <= $1 and abs(b) < (select 3);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 execute ps2(1);
                             QUERY PLAN                             
 -------------------------------------------------------------------
@@ -3535,7 +3535,7 @@ insert into boolvalues values('t'),('f');
 create table boolp (a bool) partition by list (a);
 create table boolp_t partition of boolp for values in('t');
 create table boolp_f partition of boolp for values in('f');
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from boolp where a = (select value from boolvalues where value);
                           QUERY PLAN                          
 --------------------------------------------------------------
@@ -3550,7 +3550,7 @@ select * from boolp where a = (select value from boolvalues where value);
          Filter: (a = (InitPlan expr_1).col1)
 (9 rows)
 
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from boolp where a = (select value from boolvalues where not value);
                           QUERY PLAN                          
 --------------------------------------------------------------
diff --git a/src/test/regress/sql/explain.sql b/src/test/regress/sql/explain.sql
index dbd210a07f2..b7907a228fa 100644
--- a/src/test/regress/sql/explain.sql
+++ b/src/test/regress/sql/explain.sql
@@ -65,7 +65,7 @@ explain (costs off) select 1 as a, 2 as b having false;
 select explain_filter('explain select * from int8_tbl i8');
 select explain_filter('explain (analyze, buffers off, io off) select * from int8_tbl i8');
 select explain_filter('explain (analyze, buffers off, io off, verbose) select * from int8_tbl i8');
-select explain_filter('explain (analyze, buffers, format text) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers, io off, format text) select * from int8_tbl i8');
 select explain_filter('explain (buffers, format text) select * from int8_tbl i8');
 
 \a
@@ -82,7 +82,7 @@ select explain_filter('explain verbose select sum(unique1) over w1, sum(unique2)
 -- Check output including I/O timings.  These fields are conditional
 -- but always set in JSON format, so check them only in this case.
 set track_io_timing = on;
-select explain_filter('explain (analyze, buffers, format json) select * from int8_tbl i8');
+select explain_filter('explain (analyze, buffers, io off, format json) select * from int8_tbl i8');
 set track_io_timing = off;
 
 -- SETTINGS option
@@ -107,7 +107,7 @@ select explain_filter('explain (analyze, generic_plan) select unique1 from tenk1
 select explain_filter('explain (memory) select * from int8_tbl i8');
 select explain_filter('explain (memory, analyze, buffers off, io off) select * from int8_tbl i8');
 select explain_filter('explain (memory, summary, format yaml) select * from int8_tbl i8');
-select explain_filter('explain (memory, analyze, format json) select * from int8_tbl i8');
+select explain_filter('explain (memory, analyze, io off, format json) select * from int8_tbl i8');
 prepare int8_query as select * from int8_tbl i8;
 select explain_filter('explain (memory) execute int8_query');
 
@@ -147,7 +147,7 @@ set min_parallel_table_scan_size=0;
 set max_parallel_workers_per_gather=4;
 
 select jsonb_pretty(
-  explain_filter_to_json('explain (analyze, verbose, buffers, format json)
+  explain_filter_to_json('explain (analyze, verbose, buffers, io off, format json)
                          select * from tenk1 order by tenthous')
   -- remove "Workers" node of the Seq Scan plan node
   #- '{0,Plan,Plans,0,Plans,0,Workers}'
@@ -178,8 +178,8 @@ select explain_filter('explain (verbose) create table test_ctas as select 1');
 
 -- Test SERIALIZE option
 select explain_filter('explain (analyze,buffers off,io off,serialize) select * from int8_tbl i8');
-select explain_filter('explain (analyze,serialize text,buffers,timing off) select * from int8_tbl i8');
-select explain_filter('explain (analyze,serialize binary,buffers,timing) select * from int8_tbl i8');
+select explain_filter('explain (analyze,serialize text,buffers,io off,timing off) select * from int8_tbl i8');
+select explain_filter('explain (analyze,serialize binary,buffers,io off,timing) select * from int8_tbl i8');
 -- this tests an edge case where we have no data to return
 select explain_filter('explain (analyze,buffers off,io off,serialize) create temp table explain_temp as select * from int8_tbl i8');
 
diff --git a/src/test/regress/sql/partition_prune.sql b/src/test/regress/sql/partition_prune.sql
index 212de0e6285..c5968932aed 100644
--- a/src/test/regress/sql/partition_prune.sql
+++ b/src/test/regress/sql/partition_prune.sql
@@ -553,13 +553,13 @@ begin;
 create function list_part_fn(int) returns int as $$ begin return $1; end;$$ language plpgsql stable;
 
 -- Ensure pruning works using a stable function containing no Vars
-explain (analyze, costs off, summary off, timing off, buffers off) select * from list_part where a = list_part_fn(1);
+explain (analyze, costs off, summary off, timing off, buffers off, io off) select * from list_part where a = list_part_fn(1);
 
 -- Ensure pruning does not take place when the function has a Var parameter
-explain (analyze, costs off, summary off, timing off, buffers off) select * from list_part where a = list_part_fn(a);
+explain (analyze, costs off, summary off, timing off, buffers off, io off) select * from list_part where a = list_part_fn(a);
 
 -- Ensure pruning does not take place when the expression contains a Var.
-explain (analyze, costs off, summary off, timing off, buffers off) select * from list_part where a = list_part_fn(1) + a;
+explain (analyze, costs off, summary off, timing off, buffers off, io off) select * from list_part where a = list_part_fn(1) + a;
 
 rollback;
 
@@ -582,7 +582,7 @@ declare
     ln text;
 begin
     for ln in
-        execute format('explain (analyze, costs off, summary off, timing off, buffers off) %s',
+        execute format('explain (analyze, costs off, summary off, timing off, buffers off, io off) %s',
             $1)
     loop
         ln := regexp_replace(ln, 'Workers Launched: \d+', 'Workers Launched: N');
@@ -669,7 +669,7 @@ reset min_parallel_table_scan_size;
 reset max_parallel_workers_per_gather;
 
 -- Test run-time partition pruning with an initplan
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from ab where a = (select max(a) from lprt_a) and b = (select max(a)-1 from lprt_a);
 
 -- Test run-time partition pruning with UNION ALL parents
@@ -697,7 +697,7 @@ union all
 ) ab where a = $1 and b = (select -10);
 
 -- Ensure the xy_1 subplan is not pruned.
-explain (analyze, costs off, summary off, timing off, buffers off) execute ab_q6(1);
+explain (analyze, costs off, summary off, timing off, buffers off, io off) execute ab_q6(1);
 
 -- Ensure we see just the xy_1 row.
 execute ab_q6(100);
@@ -752,10 +752,10 @@ insert into tprt values (10), (20), (501), (502), (505), (1001), (4500);
 set enable_hashjoin = off;
 set enable_mergejoin = off;
 
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 join tprt on tbl1.col1 > tprt.col1;
 
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
 
 select tbl1.col1, tprt.col1 from tbl1
@@ -768,10 +768,10 @@ order by tbl1.col1, tprt.col1;
 
 -- Multiple partitions
 insert into tbl1 values (1001), (1010), (1011);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 inner join tprt on tbl1.col1 > tprt.col1;
 
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 inner join tprt on tbl1.col1 = tprt.col1;
 
 select tbl1.col1, tprt.col1 from tbl1
@@ -785,7 +785,7 @@ order by tbl1.col1, tprt.col1;
 -- Last partition
 delete from tbl1;
 insert into tbl1 values (4400);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 join tprt on tbl1.col1 < tprt.col1;
 
 select tbl1.col1, tprt.col1 from tbl1
@@ -795,7 +795,7 @@ order by tbl1.col1, tprt.col1;
 -- No matching partition
 delete from tbl1;
 insert into tbl1 values (10000);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from tbl1 join tprt on tbl1.col1 = tprt.col1;
 
 select tbl1.col1, tprt.col1 from tbl1
@@ -917,7 +917,7 @@ create table mc3p2 partition of mc3p
   for values from (2, minvalue, minvalue) to (3, maxvalue, maxvalue);
 insert into mc3p values (0, 1, 1), (1, 1, 1), (2, 1, 1);
 
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from mc3p where a < 3 and abs(b) = 1;
 
 --
@@ -927,12 +927,12 @@ select * from mc3p where a < 3 and abs(b) = 1;
 --
 prepare ps1 as
   select * from mc3p where a = $1 and abs(b) < (select 3);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 execute ps1(1);
 deallocate ps1;
 prepare ps2 as
   select * from mc3p where a <= $1 and abs(b) < (select 3);
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 execute ps2(1);
 deallocate ps2;
 
@@ -946,10 +946,10 @@ create table boolp (a bool) partition by list (a);
 create table boolp_t partition of boolp for values in('t');
 create table boolp_f partition of boolp for values in('f');
 
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from boolp where a = (select value from boolvalues where value);
 
-explain (analyze, costs off, summary off, timing off, buffers off)
+explain (analyze, costs off, summary off, timing off, buffers off, io off)
 select * from boolp where a = (select value from boolvalues where not value);
 
 drop table boolp;
-- 
2.53.0

