public inbox for [email protected]  
help / color / mirror / Atom feed
From: Alena Rybakina <[email protected]>
To: pgsql-hackers <[email protected]>
To: Masahiko Sawada <[email protected]>
Cc: Amit Kapila <[email protected]>
Cc: Jim Nasby <[email protected]>
Cc: Bertrand Drouvot <[email protected]>
Cc: Kirill Reshke <[email protected]>
Cc: Masahiko Sawada <[email protected]>
Cc: Melanie Plageman <[email protected]>
Cc: jian he <[email protected]>
Cc: Sami Imseih <[email protected]>
Cc: vignesh C <[email protected]>
Cc: Alexander Korotkov <[email protected]>
Cc: Ilia Evdokimov <[email protected]>
Cc: Andrey Borodin <[email protected]>
Cc: Andrei Zubkov <[email protected]>
Cc: Andrei Lepikhov <[email protected]>
Subject: Re: Vacuum statistics
Date: Tue, 16 Jun 2026 18:09:53 +0300
Message-ID: <[email protected]> (raw)
In-Reply-To: <[email protected]>
References: <[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>

Hi, all!

Attached is a reworked version of the patch set. The main change is that the
statistics are no longer exposed through a contrib extension; they are now
built-in system views - pg_stat_vacuum_tables, pg_stat_vacuum_indexes and
pg_stat_vacuum_database - backed by pg_stat_get_vacuum_*() functions and 
stored
in the cumulative statistics system under a dedicated stats kind
(PGSTAT_KIND_VACUUM_RELATION). Collection is controlled by a single
track_vacuum_statistics GUC; the per-relation tracking list, the reset
function and the shared-memory-size helper from the extension version 
are gone.

The series is also split into smaller, self-contained commits - one per 
metric
category (core heap/tuple counters, missed dead tuples/pages, visibility-map
transitions, the buffer/WAL/timing sampling machinery, per-relation buffers,
timing and the wraparound failsafe, and WAL) - with the documentation and
tests growing inside each commit.

Other changes:

  * index statistics are accumulated across the bulkdelete and cleanup
    passes and reported once per index, in both the serial and parallel
    paths;
  * index_vacuum_count is no longer exposed;
  * the regression coverage is now an in-core vacuum_stats test with
    deterministic checks and dedicated scenarios for the non-trivial
    paths (truncation, freeze, cost delay, WAL full-page images, index
    page deletion), an isolation test for recently_dead/missed_dead
    tuples, and a xid_wraparound TAP test for the failsafe counter.

-- 
-----------
Best regards,
Alena Rybakina
Yandex Cloud

From b241935e671fc3ace8f800096f48d4cfd876b6bb Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Mon, 30 Mar 2026 09:07:24 +0300
Subject: [PATCH 1/8] Track table VM stability.

Add rev_all_visible_pages and rev_all_frozen_pages counters to
pg_stat_all_tables tracking the number of times the all-visible and
all-frozen bits are cleared in the visibility map. These bits are cleared by
backend processes during regular DML operations. Hence, the counters are placed
in table statistic entry.

A high rev_all_visible_pages rate relative to DML volume indicates
that modifications are scattered across previously-clean pages rather
than concentrated on already-dirty ones, causing index-only scans to
fall back to heap fetches.  A high rev_all_frozen_pages rate indicates
that vacuum's freezing work is being frequently undone by concurrent
DML.

Authors: Alena Rybakina <[email protected]>,
         Andrei Lepikhov <[email protected]>,
         Andrei Zubkov <[email protected]>
Reviewed-by: Dilip Kumar <[email protected]>,
         Masahiko Sawada <[email protected]>,
         Ilia Evdokimov <[email protected]>,
         Jian He <[email protected]>,
         Kirill Reshke <[email protected]>,
         Alexander Korotkov <[email protected]>,
         Jim Nasby <[email protected]>,
         Sami Imseih <[email protected]>,
         Karina Litskevich <[email protected]>,
         Andrey Borodin <[email protected]>
---
 doc/src/sgml/monitoring.sgml                  |  32 +++
 src/backend/access/heap/visibilitymap.c       |  10 +
 src/backend/catalog/system_views.sql          |   4 +-
 src/backend/utils/activity/pgstat_relation.c  |   2 +
 src/backend/utils/adt/pgstatfuncs.c           |   6 +
 src/include/catalog/pg_proc.dat               |  10 +
 src/include/pgstat.h                          |  17 +-
 .../expected/vacuum-extending-freeze.out      | 185 ++++++++++++++++++
 src/test/isolation/isolation_schedule         |   2 +
 .../specs/vacuum-extending-freeze.spec        | 117 +++++++++++
 src/test/regress/expected/rules.out           |  12 +-
 11 files changed, 392 insertions(+), 5 deletions(-)
 create mode 100644 src/test/isolation/expected/vacuum-extending-freeze.out
 create mode 100644 src/test/isolation/specs/vacuum-extending-freeze.spec

diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index 08d5b82455..3467abf6d8 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -4377,6 +4377,38 @@ description | Waiting for a newly initialized WAL file to reach durable storage
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>visible_page_marks_cleared</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of times the all-visible mark in the
+       <link linkend="storage-vm">visibility map</link> was cleared for
+       pages of this table.  The all-visible mark of a heap page is
+       cleared whenever a backend process modifies a page that was
+       previously marked all-visible by vacuum activity (whether manual
+       <command>VACUUM</command> or autovacuum).  The page must then be
+       processed again by vacuum on a subsequent run.  A high rate of
+       change in this counter means that vacuum has to repeatedly
+       re-process pages of this table.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>frozen_page_marks_cleared</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of times the all-frozen mark in the
+       <link linkend="storage-vm">visibility map</link> was cleared for
+       pages of this table.  The all-frozen mark of a heap page is cleared
+       whenever a backend process modifies a page that was previously
+       marked all-frozen by vacuum activity (manual <command>VACUUM</command>
+       or autovacuum).  The page must then be processed again by vacuum on
+       the next freeze run for this table.
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>last_vacuum</structfield> <type>timestamp with time zone</type>
diff --git a/src/backend/access/heap/visibilitymap.c b/src/backend/access/heap/visibilitymap.c
index 4fd470702a..f055ec3819 100644
--- a/src/backend/access/heap/visibilitymap.c
+++ b/src/backend/access/heap/visibilitymap.c
@@ -102,6 +102,7 @@
 #include "access/xloginsert.h"
 #include "access/xlogutils.h"
 #include "miscadmin.h"
+#include "pgstat.h"
 #include "port/pg_bitutils.h"
 #include "storage/bufmgr.h"
 #include "storage/smgr.h"
@@ -173,6 +174,15 @@ visibilitymap_clear(Relation rel, BlockNumber heapBlk, Buffer vmbuf, uint8 flags
 
 	if (map[mapByte] & mask)
 	{
+		/*
+		 * Track how often all-visible or all-frozen bits are cleared in the
+		 * visibility map.
+		 */
+		if (map[mapByte] & ((flags & VISIBILITYMAP_ALL_VISIBLE) << mapOffset))
+			pgstat_count_visible_page_marks_cleared(rel);
+		if (map[mapByte] & ((flags & VISIBILITYMAP_ALL_FROZEN) << mapOffset))
+			pgstat_count_frozen_page_marks_cleared(rel);
+
 		map[mapByte] &= ~mask;
 
 		MarkBufferDirty(vmbuf);
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 8f129baec9..b57128bb12 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -747,7 +747,9 @@ CREATE VIEW pg_stat_all_tables AS
             pg_stat_get_total_autovacuum_time(C.oid) AS total_autovacuum_time,
             pg_stat_get_total_analyze_time(C.oid) AS total_analyze_time,
             pg_stat_get_total_autoanalyze_time(C.oid) AS total_autoanalyze_time,
-            pg_stat_get_stat_reset_time(C.oid) AS stats_reset
+            pg_stat_get_stat_reset_time(C.oid) AS stats_reset,
+            pg_stat_get_visible_page_marks_cleared(C.oid) AS visible_page_marks_cleared,
+            pg_stat_get_frozen_page_marks_cleared(C.oid) AS frozen_page_marks_cleared
     FROM pg_class C LEFT JOIN
          pg_index I ON C.oid = I.indrelid
          LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c
index b2ca28f83b..92e1f60a08 100644
--- a/src/backend/utils/activity/pgstat_relation.c
+++ b/src/backend/utils/activity/pgstat_relation.c
@@ -881,6 +881,8 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 
 	tabentry->blocks_fetched += lstats->counts.blocks_fetched;
 	tabentry->blocks_hit += lstats->counts.blocks_hit;
+	tabentry->visible_page_marks_cleared += lstats->counts.visible_page_marks_cleared;
+	tabentry->frozen_page_marks_cleared += lstats->counts.frozen_page_marks_cleared;
 
 	/* Clamp live_tuples in case of negative delta_live_tuples */
 	tabentry->live_tuples = Max(tabentry->live_tuples, 0);
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 6f9c9c72de..6409987d66 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -108,6 +108,12 @@ PG_STAT_GET_RELENTRY_INT64(tuples_updated)
 /* pg_stat_get_vacuum_count */
 PG_STAT_GET_RELENTRY_INT64(vacuum_count)
 
+/* pg_stat_get_visible_page_marks_cleared */
+PG_STAT_GET_RELENTRY_INT64(visible_page_marks_cleared)
+
+/* pg_stat_get_frozen_page_marks_cleared */
+PG_STAT_GET_RELENTRY_INT64(frozen_page_marks_cleared)
+
 #define PG_STAT_GET_RELENTRY_FLOAT8(stat)						\
 Datum															\
 CppConcat(pg_stat_get_,stat)(PG_FUNCTION_ARGS)					\
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index be157a5fbe..291b039859 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12693,4 +12693,14 @@
   proname => 'hashoid8extended', prorettype => 'int8',
   proargtypes => 'oid8 int8', prosrc => 'hashoid8extended' },
 
+{ oid => '8002',
+  descr => 'statistics: number of times the all-visible marks in the visibility map were cleared for pages of this table',
+  proname => 'pg_stat_get_visible_page_marks_cleared', provolatile => 's',
+  proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
+  prosrc => 'pg_stat_get_visible_page_marks_cleared' },
+{ oid => '8003',
+  descr => 'statistics: number of times the all-frozen marks in the visibility map were cleared for pages of this table',
+  proname => 'pg_stat_get_frozen_page_marks_cleared', provolatile => 's',
+  proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
+  prosrc => 'pg_stat_get_frozen_page_marks_cleared' },
 ]
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index dfa2e83763..7db36cf8ad 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -160,6 +160,8 @@ typedef struct PgStat_TableCounts
 
 	PgStat_Counter blocks_fetched;
 	PgStat_Counter blocks_hit;
+	PgStat_Counter visible_page_marks_cleared;
+	PgStat_Counter frozen_page_marks_cleared;
 } PgStat_TableCounts;
 
 /* ----------
@@ -218,7 +220,7 @@ typedef struct PgStat_TableXactStatus
  * ------------------------------------------------------------
  */
 
-#define PGSTAT_FILE_FORMAT_ID	0x01A5BCBC
+#define PGSTAT_FILE_FORMAT_ID	0x01A5BCBD
 
 typedef struct PgStat_ArchiverStats
 {
@@ -469,6 +471,8 @@ typedef struct PgStat_StatTabEntry
 
 	PgStat_Counter blocks_fetched;
 	PgStat_Counter blocks_hit;
+	PgStat_Counter visible_page_marks_cleared;
+	PgStat_Counter frozen_page_marks_cleared;
 
 	TimestampTz last_vacuum_time;	/* user initiated vacuum */
 	PgStat_Counter vacuum_count;
@@ -749,6 +753,17 @@ extern void pgstat_report_analyze(Relation rel,
 		if (pgstat_should_count_relation(rel))						\
 			(rel)->pgstat_info->counts.blocks_hit++;				\
 	} while (0)
+/* count revocations of all-visible and all-frozen marks in visibility map */
+#define pgstat_count_visible_page_marks_cleared(rel)					\
+	do {															\
+		if (pgstat_should_count_relation(rel))						\
+			(rel)->pgstat_info->counts.visible_page_marks_cleared++;	\
+	} while (0)
+#define pgstat_count_frozen_page_marks_cleared(rel)					\
+	do {															\
+		if (pgstat_should_count_relation(rel))						\
+			(rel)->pgstat_info->counts.frozen_page_marks_cleared++;	\
+	} while (0)
 
 extern void pgstat_count_heap_insert(Relation rel, PgStat_Counter n);
 extern void pgstat_count_heap_update(Relation rel, bool hot, bool newpage);
diff --git a/src/test/isolation/expected/vacuum-extending-freeze.out b/src/test/isolation/expected/vacuum-extending-freeze.out
new file mode 100644
index 0000000000..994a8df56d
--- /dev/null
+++ b/src/test/isolation/expected/vacuum-extending-freeze.out
@@ -0,0 +1,185 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s2_vacuum_freeze s1_get_set_vm_flags_stats s1_update_table s1_get_cleared_vm_flags_stats s2_vacuum_freeze s1_get_set_vm_flags_stats s2_vacuum_freeze s1_select_from_index s2_delete_from_table s1_get_cleared_vm_flags_stats s2_vacuum_freeze s1_get_set_vm_flags_stats s1_commit s1_get_cleared_vm_flags_stats
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+step s2_vacuum_freeze: 
+    VACUUM FREEZE vestat;
+
+step s1_get_set_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT c.relallfrozen > frozen_flag_count as relallfrozen, c.relallvisible > all_visibile_flag_count as relallvisible
+        FROM pg_class c, stats_state
+        WHERE c.relname = 'vestat';
+
+    UPDATE stats_state
+        SET frozen_flag_count = c.relallfrozen,
+            all_visibile_flag_count = c.relallvisible
+        FROM pg_class c
+        WHERE c.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+relallfrozen|relallvisible
+------------+-------------
+t           |t            
+(1 row)
+
+step s1_update_table: 
+    UPDATE vestat SET x = x + 1001 where x >= 2500;
+    SELECT pg_stat_force_next_flush();
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+step s1_get_cleared_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT v.visible_page_marks_cleared > cleared_all_visibile_flag_count as visible_page_marks_cleared,
+           v.frozen_page_marks_cleared > cleared_frozen_flag_count as frozen_page_marks_cleared
+        FROM pg_stat_all_tables v, stats_state
+        WHERE v.relname = 'vestat';
+
+    UPDATE stats_state
+        SET cleared_all_visibile_flag_count = v.visible_page_marks_cleared,
+            cleared_frozen_flag_count = v.frozen_page_marks_cleared
+        FROM pg_stat_all_tables v
+        WHERE v.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+visible_page_marks_cleared|frozen_page_marks_cleared
+--------------------------+-------------------------
+t                         |t                        
+(1 row)
+
+step s2_vacuum_freeze: 
+    VACUUM FREEZE vestat;
+
+step s1_get_set_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT c.relallfrozen > frozen_flag_count as relallfrozen, c.relallvisible > all_visibile_flag_count as relallvisible
+        FROM pg_class c, stats_state
+        WHERE c.relname = 'vestat';
+
+    UPDATE stats_state
+        SET frozen_flag_count = c.relallfrozen,
+            all_visibile_flag_count = c.relallvisible
+        FROM pg_class c
+        WHERE c.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+relallfrozen|relallvisible
+------------+-------------
+t           |t            
+(1 row)
+
+step s2_vacuum_freeze: 
+    VACUUM FREEZE vestat;
+
+step s1_select_from_index: 
+    BEGIN;
+    SELECT count(x) FROM vestat WHERE x > 2000;
+
+count
+-----
+ 3000
+(1 row)
+
+step s2_delete_from_table: 
+    DELETE FROM vestat WHERE x > 4930;
+
+step s1_get_cleared_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT v.visible_page_marks_cleared > cleared_all_visibile_flag_count as visible_page_marks_cleared,
+           v.frozen_page_marks_cleared > cleared_frozen_flag_count as frozen_page_marks_cleared
+        FROM pg_stat_all_tables v, stats_state
+        WHERE v.relname = 'vestat';
+
+    UPDATE stats_state
+        SET cleared_all_visibile_flag_count = v.visible_page_marks_cleared,
+            cleared_frozen_flag_count = v.frozen_page_marks_cleared
+        FROM pg_stat_all_tables v
+        WHERE v.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+visible_page_marks_cleared|frozen_page_marks_cleared
+--------------------------+-------------------------
+f                         |f                        
+(1 row)
+
+step s2_vacuum_freeze: 
+    VACUUM FREEZE vestat;
+
+step s1_get_set_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT c.relallfrozen > frozen_flag_count as relallfrozen, c.relallvisible > all_visibile_flag_count as relallvisible
+        FROM pg_class c, stats_state
+        WHERE c.relname = 'vestat';
+
+    UPDATE stats_state
+        SET frozen_flag_count = c.relallfrozen,
+            all_visibile_flag_count = c.relallvisible
+        FROM pg_class c
+        WHERE c.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+relallfrozen|relallvisible
+------------+-------------
+f           |f            
+(1 row)
+
+step s1_commit: 
+    COMMIT;
+
+step s1_get_cleared_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT v.visible_page_marks_cleared > cleared_all_visibile_flag_count as visible_page_marks_cleared,
+           v.frozen_page_marks_cleared > cleared_frozen_flag_count as frozen_page_marks_cleared
+        FROM pg_stat_all_tables v, stats_state
+        WHERE v.relname = 'vestat';
+
+    UPDATE stats_state
+        SET cleared_all_visibile_flag_count = v.visible_page_marks_cleared,
+            cleared_frozen_flag_count = v.frozen_page_marks_cleared
+        FROM pg_stat_all_tables v
+        WHERE v.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+visible_page_marks_cleared|frozen_page_marks_cleared
+--------------------------+-------------------------
+t                         |t                        
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index 15c33fad4c..3d59eef7a7 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -102,6 +102,7 @@ test: timeouts
 test: vacuum-concurrent-drop
 test: vacuum-conflict
 test: vacuum-skip-locked
+test: vacuum-extending-in-repetable-read
 test: stats
 test: horizons
 test: predicate-hash
@@ -127,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: vacuum-extending-freeze
diff --git a/src/test/isolation/specs/vacuum-extending-freeze.spec b/src/test/isolation/specs/vacuum-extending-freeze.spec
new file mode 100644
index 0000000000..17c204e232
--- /dev/null
+++ b/src/test/isolation/specs/vacuum-extending-freeze.spec
@@ -0,0 +1,117 @@
+# In short, this test validates the correctness and stability of cumulative
+# vacuum statistics accounting around freezing, visibility, and revision
+# tracking across VACUUM and backend operations.
+# In addition, the test provides a scenario where one process holds a
+# transaction open while another process deletes tuples. We expect that
+# a backend clears the all-frozen and all-visible flags, which were set
+# by VACUUM earlier, only after the committing transaction makes the
+# deletions visible.
+
+setup
+{
+    CREATE TABLE vestat (x int, y int)
+        WITH (autovacuum_enabled = off, fillfactor = 70);
+
+    INSERT INTO vestat
+        SELECT i, i FROM generate_series(1, 5000) AS g(i);
+
+    CREATE INDEX vestat_idx ON vestat (x);
+
+    CREATE TABLE stats_state (frozen_flag_count int, all_visibile_flag_count int,
+                        cleared_frozen_flag_count int, cleared_all_visibile_flag_count int);
+    INSERT INTO stats_state VALUES (0,0,0,0);
+    ANALYZE vestat;
+
+    -- Ensure stats are flushed before starting the scenario
+    SELECT pg_stat_force_next_flush();
+}
+
+teardown
+{
+    DROP TABLE IF EXISTS vestat;
+    RESET vacuum_freeze_min_age;
+    RESET vacuum_freeze_table_age;
+
+}
+
+session s1
+
+step s1_get_set_vm_flags_stats
+{
+    SELECT pg_stat_force_next_flush();
+
+    SELECT c.relallfrozen > frozen_flag_count as relallfrozen, c.relallvisible > all_visibile_flag_count as relallvisible
+        FROM pg_class c, stats_state
+        WHERE c.relname = 'vestat';
+
+    UPDATE stats_state
+        SET frozen_flag_count = c.relallfrozen,
+            all_visibile_flag_count = c.relallvisible
+        FROM pg_class c
+        WHERE c.relname = 'vestat';
+}
+
+step s1_get_cleared_vm_flags_stats
+{
+    SELECT pg_stat_force_next_flush();
+
+    SELECT v.visible_page_marks_cleared > cleared_all_visibile_flag_count as visible_page_marks_cleared,
+           v.frozen_page_marks_cleared > cleared_frozen_flag_count as frozen_page_marks_cleared
+        FROM pg_stat_all_tables v, stats_state
+        WHERE v.relname = 'vestat';
+
+    UPDATE stats_state
+        SET cleared_all_visibile_flag_count = v.visible_page_marks_cleared,
+            cleared_frozen_flag_count = v.frozen_page_marks_cleared
+        FROM pg_stat_all_tables v
+        WHERE v.relname = 'vestat';
+}
+
+step s1_select_from_index
+{
+    BEGIN;
+    SELECT count(x) FROM vestat WHERE x > 2000;
+}
+
+step s1_commit
+{
+    COMMIT;
+}
+
+session s2
+setup
+{
+    -- Configure aggressive freezing vacuum behavior
+    SET vacuum_freeze_min_age = 0;
+    SET vacuum_freeze_table_age = 0;
+}
+step s2_delete_from_table
+{
+    DELETE FROM vestat WHERE x > 4930;
+}
+step s2_vacuum_freeze
+{
+    VACUUM FREEZE vestat;
+}
+
+step s1_update_table
+{
+    UPDATE vestat SET x = x + 1001 where x >= 2500;
+    SELECT pg_stat_force_next_flush();
+}
+
+permutation
+    s2_vacuum_freeze
+    s1_get_set_vm_flags_stats
+    s1_update_table
+    s1_get_cleared_vm_flags_stats
+    s2_vacuum_freeze
+    s1_get_set_vm_flags_stats
+    s2_vacuum_freeze
+    s1_select_from_index
+    s2_delete_from_table
+    s1_get_cleared_vm_flags_stats
+    s2_vacuum_freeze
+    s1_get_set_vm_flags_stats
+    s1_commit
+    s1_get_cleared_vm_flags_stats
\ No newline at end of file
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index a65a5bf0c4..096e4f763f 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -1846,7 +1846,9 @@ pg_stat_all_tables| SELECT c.oid AS relid,
     pg_stat_get_total_autovacuum_time(c.oid) AS total_autovacuum_time,
     pg_stat_get_total_analyze_time(c.oid) AS total_analyze_time,
     pg_stat_get_total_autoanalyze_time(c.oid) AS total_autoanalyze_time,
-    pg_stat_get_stat_reset_time(c.oid) AS stats_reset
+    pg_stat_get_stat_reset_time(c.oid) AS stats_reset,
+    pg_stat_get_visible_page_marks_cleared(c.oid) AS visible_page_marks_cleared,
+    pg_stat_get_frozen_page_marks_cleared(c.oid) AS frozen_page_marks_cleared
    FROM ((pg_class c
      LEFT JOIN pg_index i ON ((c.oid = i.indrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace)))
@@ -2357,7 +2359,9 @@ pg_stat_sys_tables| SELECT relid,
     total_autovacuum_time,
     total_analyze_time,
     total_autoanalyze_time,
-    stats_reset
+    stats_reset,
+    visible_page_marks_cleared,
+    frozen_page_marks_cleared
    FROM pg_stat_all_tables
   WHERE ((schemaname = ANY (ARRAY['pg_catalog'::name, 'information_schema'::name])) OR (schemaname ~ '^pg_toast'::text));
 pg_stat_user_functions| SELECT p.oid AS funcid,
@@ -2412,7 +2416,9 @@ pg_stat_user_tables| SELECT relid,
     total_autovacuum_time,
     total_analyze_time,
     total_autoanalyze_time,
-    stats_reset
+    stats_reset,
+    visible_page_marks_cleared,
+    frozen_page_marks_cleared
    FROM pg_stat_all_tables
   WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text));
 pg_stat_wal| SELECT wal_records,
-- 
2.39.5 (Apple Git-154)


From 41e5aeac7c3443912e0e020816eb0cbb649c2dbb Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:06:53 +0300
Subject: [PATCH 2/8] Extended vacuum statistics: core heap and tuple metrics
 for tables and indexes

Expose the core per-table heap and dead-tuple metrics of the last
VACUUM through the new pg_stat_vacuum_tables view and
pg_stat_get_vacuum_tables() function, with documentation and regression
coverage:

  pages_scanned         heap pages examined (not skipped via the VM)
  pages_removed         heap pages by which storage was truncated
  tuples_deleted        dead tuples removed by the vacuum
  tuples_frozen         tuples frozen by the vacuum
  recently_dead_tuples  dead tuples still visible to some transaction and
                        therefore not yet removable

The matching per-index core metrics are exposed at the same time through the
new pg_stat_vacuum_indexes view and pg_stat_get_vacuum_indexes() function:

  pages_deleted   index pages deleted (made reusable) by the vacuum
  tuples_deleted  index entries removed by the vacuum

This first commit also adds the infrastructure the rest of the series builds
on.  Extended vacuum statistics are stored in the cumulative statistics system
under a dedicated stats kind, PGSTAT_KIND_VACUUM_RELATION, kept separate from
the regular relation statistics. They are collected during vacuum and
accumulated per relation, only while the track_vacuum_statistics GUC is
enabled.  Indexes are tracked under the same stats kind, with their totals
reported once per index at the end of the vacuum.

The vacuum-extending-in-repetable-read isolation test is added here to cover
recently_dead_tuples: a REPEATABLE READ transaction keeps recently deleted
tuples visible, so VACUUM cannot remove them and reports them as
recently_dead_tuples rather than tuples_deleted.
---
 doc/src/sgml/system-views.sgml                | 190 ++++++++++++++++++
 src/backend/access/heap/vacuumlazy.c          |  79 ++++++++
 src/backend/catalog/heap.c                    |   1 +
 src/backend/catalog/index.c                   |   1 +
 src/backend/catalog/system_views.sql          |  35 ++++
 src/backend/commands/vacuumparallel.c         |  27 +++
 src/backend/utils/activity/Makefile           |   1 +
 src/backend/utils/activity/meson.build        |   1 +
 src/backend/utils/activity/pgstat.c           |  15 +-
 src/backend/utils/activity/pgstat_relation.c  |   6 +
 src/backend/utils/activity/pgstat_vacuum.c    | 126 ++++++++++++
 src/backend/utils/adt/pgstatfuncs.c           |  84 ++++++++
 src/backend/utils/misc/guc_parameters.dat     |   6 +
 src/backend/utils/misc/postgresql.conf.sample |   1 +
 src/include/catalog/pg_proc.dat               |  19 ++
 src/include/pgstat.h                          |  83 +++++++-
 src/include/utils/pgstat_internal.h           |   8 +
 src/include/utils/pgstat_kind.h               |   3 +-
 .../vacuum-extending-in-repetable-read.out    |  38 ++++
 .../vacuum-extending-in-repetable-read.spec   |  53 +++++
 src/test/regress/expected/rules.out           |  25 +++
 src/test/regress/expected/vacuum_stats.out    |  87 ++++++++
 src/test/regress/parallel_schedule            |   3 +
 src/test/regress/sql/vacuum_stats.sql         |  68 +++++++
 24 files changed, 957 insertions(+), 3 deletions(-)
 create mode 100644 src/backend/utils/activity/pgstat_vacuum.c
 create mode 100644 src/test/isolation/expected/vacuum-extending-in-repetable-read.out
 create mode 100644 src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
 create mode 100644 src/test/regress/expected/vacuum_stats.out
 create mode 100644 src/test/regress/sql/vacuum_stats.sql

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 2ebec6928d..bdcd8d9f47 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5782,4 +5782,194 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
   </table>
  </sect1>
 
+ <sect1 id="view-pg-stat-vacuum-tables">
+  <title><structname>pg_stat_vacuum_tables</structname></title>
+
+  <indexterm zone="view-pg-stat-vacuum-tables">
+   <primary>pg_stat_vacuum_tables</primary>
+  </indexterm>
+
+  <para>
+   The <structname>pg_stat_vacuum_tables</structname> view will contain one row
+   for each table in the current database, showing extended statistics about
+   the activity of the most recent <command>VACUUM</command> on that table.
+   These statistics are accumulated only while
+   <varname>track_vacuum_statistics</varname> is enabled.
+  </para>
+
+  <table id="view-pg-stat-vacuum-tables-cols">
+   <title><structname>pg_stat_vacuum_tables</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>schemaname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the schema that the table is in.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the table.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the table.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_scanned</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages examined by the vacuum (pages that were not skipped using the visibility map).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_removed</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages by which the table's storage was physically reduced (truncated) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of dead tuples removed by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_frozen</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of tuples frozen by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>recently_dead_tuples</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of dead tuples that are still visible to some transaction and therefore could not yet be removed.
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
+ <sect1 id="view-pg-stat-vacuum-indexes">
+  <title><structname>pg_stat_vacuum_indexes</structname></title>
+
+  <indexterm zone="view-pg-stat-vacuum-indexes">
+   <primary>pg_stat_vacuum_indexes</primary>
+  </indexterm>
+
+  <para>
+   The <structname>pg_stat_vacuum_indexes</structname> view will contain one row
+   for each index in the current database, showing extended statistics about
+   the activity of the most recent <command>VACUUM</command> on that index.
+   These statistics are accumulated only while
+   <varname>track_vacuum_statistics</varname> is enabled.
+  </para>
+
+  <table id="view-pg-stat-vacuum-indexes-cols">
+   <title><structname>pg_stat_vacuum_indexes</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the table the index is on.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>indexrelid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>schemaname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the schema that the table is in.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the table.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>indexrelname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of index pages deleted (made reusable) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of index entries removed by the vacuum.
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 39395aed0d..3c403e86f0 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -283,6 +283,7 @@ typedef struct LVRelState
 	/* Error reporting state */
 	char	   *dbname;
 	char	   *relnamespace;
+	Oid			reloid;
 	char	   *relname;
 	char	   *indname;		/* Current index name */
 	BlockNumber blkno;			/* used only for heap operations */
@@ -483,6 +484,8 @@ static void update_vacuum_error_info(LVRelState *vacrel,
 									 OffsetNumber offnum);
 static void restore_vacuum_error_info(LVRelState *vacrel,
 									  const LVSavedErrInfo *saved_vacrel);
+static void accumulate_heap_vacuum_statistics(LVRelState *vacrel,
+											  PgStat_VacuumRelationCounts *extVacStats);
 
 
 
@@ -609,6 +612,64 @@ heap_vacuum_eager_scan_setup(LVRelState *vacrel, const VacuumParams *params)
 		first_region_ratio;
 }
 
+/*
+ * Fill the extended vacuum statistics report for a heap relation with the
+ * counters that are derived directly from the LVRelState.  Buffer, WAL and
+ * timing counters require sampling resource usage around the vacuum and are
+ * gathered separately; they are not touched here.
+ */
+static void
+accumulate_heap_vacuum_statistics(LVRelState *vacrel,
+								  PgStat_VacuumRelationCounts *extVacStats)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	extVacStats->type = PGSTAT_EXTVAC_TABLE;
+	extVacStats->table.pages_scanned = vacrel->scanned_pages;
+	extVacStats->table.pages_removed = vacrel->removed_pages;
+	extVacStats->common.tuples_deleted = vacrel->tuples_deleted;
+	extVacStats->table.tuples_frozen = vacrel->tuples_frozen;
+	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
+}
+
+/*
+ * Report the per-index extended vacuum statistics, one report per index.
+ *
+ * The per-index counters (pages_deleted and the number of removed index
+ * entries) are derived directly from each index's final IndexBulkDeleteResult,
+ * which already holds the totals accumulated across all bulkdelete and cleanup
+ * passes -- so no per-pass sampling is needed here.  Used by the non-parallel
+ * path only; the parallel path reports its DSM-resident results from
+ * parallel_vacuum_end().
+ */
+static void
+report_index_vacuum_extstats(LVRelState *vacrel)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	for (int idx = 0; idx < vacrel->nindexes; idx++)
+	{
+		Relation	indrel = vacrel->indrels[idx];
+		IndexBulkDeleteResult *istat = vacrel->indstats[idx];
+		PgStat_VacuumRelationCounts report;
+
+		/* Skip indexes that this vacuum did not process */
+		if (istat == NULL)
+			continue;
+
+		memset(&report, 0, sizeof(report));
+		report.type = PGSTAT_EXTVAC_INDEX;
+		report.common.tuples_deleted = istat->tuples_removed;
+		report.index.pages_deleted = istat->pages_deleted;
+
+		pgstat_report_vacuum_extstats(RelationGetRelid(indrel),
+									  indrel->rd_rel->relisshared,
+									  &report);
+	}
+}
+
 /*
  *	heap_vacuum_rel() -- perform VACUUM for one heap relation
  *
@@ -643,6 +704,10 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	ErrorContextCallback errcallback;
 	char	  **indnames = NULL;
 	Size		dead_items_max_bytes = 0;
+	PgStat_VacuumRelationCounts extVacReport;
+
+	/* Initialize the extended vacuum statistics report */
+	memset(&extVacReport, 0, sizeof(PgStat_VacuumRelationCounts));
 
 	verbose = (params->options & VACOPT_VERBOSE) != 0;
 	instrument = (verbose || (AmAutoVacuumWorkerProcess() &&
@@ -686,6 +751,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel = palloc0_object(LVRelState);
 	vacrel->dbname = get_database_name(MyDatabaseId);
 	vacrel->relnamespace = get_namespace_name(RelationGetNamespace(rel));
+	vacrel->reloid = RelationGetRelid(rel);
 	vacrel->relname = pstrdup(RelationGetRelationName(rel));
 	vacrel->indname = NULL;
 	vacrel->phase = VACUUM_ERRCB_PHASE_UNKNOWN;
@@ -700,6 +766,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vac_open_indexes(vacrel->rel, RowExclusiveLock, &vacrel->nindexes,
 					 &vacrel->indrels);
 	vacrel->bstrategy = bstrategy;
+
 	if (instrument && vacrel->nindexes > 0)
 	{
 		/* Copy index names used by instrumentation (not error reporting) */
@@ -985,6 +1052,9 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	 * soon in cases where the failsafe prevented significant amounts of heap
 	 * vacuuming.
 	 */
+	accumulate_heap_vacuum_statistics(vacrel, &extVacReport);
+	pgstat_report_vacuum_extstats(vacrel->reloid, rel->rd_rel->relisshared,
+								  &extVacReport);
 	pgstat_report_vacuum(rel,
 						 Max(vacrel->new_live_tuples, 0),
 						 vacrel->recently_dead_tuples +
@@ -1618,6 +1688,15 @@ lazy_scan_heap(LVRelState *vacrel)
 	/* Do final index cleanup (call each index's amvacuumcleanup routine) */
 	if (vacrel->nindexes > 0 && vacrel->do_index_cleanup)
 		lazy_cleanup_all_indexes(vacrel);
+
+	/*
+	 * Report the per-index extended vacuum statistics accumulated over all
+	 * bulkdelete and cleanup passes, exactly once per index.  The parallel
+	 * path reports its DSM-resident totals from parallel_vacuum_end() instead,
+	 * so only do it here when index vacuuming ran in the leader.
+	 */
+	if (vacrel->nindexes > 0 && !ParallelVacuumIsActive(vacrel))
+		report_index_vacuum_extstats(vacrel);
 }
 
 /*
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 88087654de..709fe33e61 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -1903,6 +1903,7 @@ heap_drop_with_catalog(Oid relid)
 
 	/* ensure that stats are dropped if transaction commits */
 	pgstat_drop_relation(rel);
+	pgstat_vacuum_relation_delete_pending_cb(RelationGetRelid(rel));
 
 	/*
 	 * Close relcache entry, but *keep* AccessExclusiveLock on the relation
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 9407c357f2..f986276677 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2345,6 +2345,7 @@ index_drop(Oid indexId, bool concurrent, bool concurrent_lock_mode)
 
 	/* ensure that stats are dropped if transaction commits */
 	pgstat_drop_relation(userIndexRelation);
+	pgstat_vacuum_relation_delete_pending_cb(RelationGetRelid(userIndexRelation));
 
 	/*
 	 * Close and flush the index's relcache entry, to ensure relcache doesn't
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index b57128bb12..01487f1665 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1559,3 +1559,38 @@ CREATE VIEW pg_aios AS
     SELECT * FROM pg_get_aios();
 REVOKE ALL ON pg_aios FROM PUBLIC;
 GRANT SELECT ON pg_aios TO pg_read_all_stats;
+
+CREATE VIEW pg_stat_vacuum_tables AS
+    SELECT
+        N.nspname AS schemaname,
+        C.relname AS relname,
+        S.relid AS relid,
+
+        S.pages_scanned AS pages_scanned,
+        S.pages_removed AS pages_removed,
+        S.tuples_deleted AS tuples_deleted,
+        S.tuples_frozen AS tuples_frozen,
+        S.recently_dead_tuples AS recently_dead_tuples
+
+    FROM pg_class C JOIN
+            pg_namespace N ON N.oid = C.relnamespace,
+            LATERAL pg_stat_get_vacuum_tables(C.oid) S
+    WHERE C.relkind IN ('r', 't', 'm');
+
+CREATE VIEW pg_stat_vacuum_indexes AS
+    SELECT
+            C.oid AS relid,
+            I.oid AS indexrelid,
+            N.nspname AS schemaname,
+            C.relname AS relname,
+            I.relname AS indexrelname,
+
+            S.pages_deleted AS pages_deleted,
+            S.tuples_deleted AS tuples_deleted
+    FROM
+            pg_class C JOIN
+            pg_index X ON C.oid = X.indrelid JOIN
+            pg_class I ON I.oid = X.indexrelid
+            LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace),
+            LATERAL pg_stat_get_vacuum_indexes(I.oid) S
+    WHERE C.relkind IN ('r', 't', 'm');
diff --git a/src/backend/commands/vacuumparallel.c b/src/backend/commands/vacuumparallel.c
index 41cefcfde5..7725c4ecc1 100644
--- a/src/backend/commands/vacuumparallel.c
+++ b/src/backend/commands/vacuumparallel.c
@@ -535,6 +535,33 @@ parallel_vacuum_end(ParallelVacuumState *pvs, IndexBulkDeleteResult **istats)
 	DestroyParallelContext(pvs->pcxt);
 	ExitParallelMode();
 
+	/*
+	 * Report the per-index extended vacuum statistics, one report per index,
+	 * derived directly from each index's final IndexBulkDeleteResult.  The
+	 * indexes are still open here (pvs->indrels is the leader's own array, not
+	 * in the now-destroyed DSM).
+	 */
+	if (pgstat_track_vacuum_statistics)
+	{
+		for (int i = 0; i < pvs->nindexes; i++)
+		{
+			Relation	indrel = pvs->indrels[i];
+			PgStat_VacuumRelationCounts report;
+
+			if (istats[i] == NULL)
+				continue;
+
+			memset(&report, 0, sizeof(report));
+			report.type = PGSTAT_EXTVAC_INDEX;
+			report.common.tuples_deleted = istats[i]->tuples_removed;
+			report.index.pages_deleted = istats[i]->pages_deleted;
+
+			pgstat_report_vacuum_extstats(RelationGetRelid(indrel),
+										  indrel->rd_rel->relisshared,
+										  &report);
+		}
+	}
+
 	if (AmAutoVacuumWorkerProcess())
 		pv_shared_cost_params = NULL;
 
diff --git a/src/backend/utils/activity/Makefile b/src/backend/utils/activity/Makefile
index ca3ef89bf5..b7db9c034a 100644
--- a/src/backend/utils/activity/Makefile
+++ b/src/backend/utils/activity/Makefile
@@ -28,6 +28,7 @@ OBJS = \
 	pgstat_io.o \
 	pgstat_lock.o \
 	pgstat_relation.o \
+	pgstat_vacuum.o \
 	pgstat_replslot.o \
 	pgstat_shmem.o \
 	pgstat_slru.o \
diff --git a/src/backend/utils/activity/meson.build b/src/backend/utils/activity/meson.build
index 1aa7ece529..2a0b50d07d 100644
--- a/src/backend/utils/activity/meson.build
+++ b/src/backend/utils/activity/meson.build
@@ -17,6 +17,7 @@ backend_sources += files(
   'pgstat_shmem.c',
   'pgstat_slru.c',
   'pgstat_subscription.c',
+  'pgstat_vacuum.c',
   'pgstat_wal.c',
   'pgstat_xact.c',
 )
diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index b67da88c7d..4e950672e2 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -204,7 +204,7 @@ static inline bool pgstat_is_kind_valid(PgStat_Kind kind);
 
 bool		pgstat_track_counts = false;
 int			pgstat_fetch_consistency = PGSTAT_FETCH_CONSISTENCY_CACHE;
-
+bool		pgstat_track_vacuum_statistics = false;
 
 /* ----------
  * state shared with pgstat_*.c
@@ -500,6 +500,19 @@ static const PgStat_KindInfo pgstat_kind_builtin_infos[PGSTAT_KIND_BUILTIN_SIZE]
 		.reset_all_cb = pgstat_wal_reset_all_cb,
 		.snapshot_cb = pgstat_wal_snapshot_cb,
 	},
+	[PGSTAT_KIND_VACUUM_RELATION] = {
+		.name = "vacuum statistics",
+
+		.fixed_amount = false,
+		.write_to_file = true,
+
+		.shared_size = sizeof(PgStatShared_VacuumRelation),
+		.shared_data_off = offsetof(PgStatShared_VacuumRelation, stats),
+		.shared_data_len = sizeof(((PgStatShared_VacuumRelation *) 0)->stats),
+		.pending_size = sizeof(PgStat_RelationVacuumPending),
+
+		.flush_pending_cb = pgstat_vacuum_relation_flush_cb
+	},
 };
 
 /*
diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c
index 92e1f60a08..141e2af607 100644
--- a/src/backend/utils/activity/pgstat_relation.c
+++ b/src/backend/utils/activity/pgstat_relation.c
@@ -904,6 +904,12 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	return true;
 }
 
+void
+pgstat_vacuum_relation_delete_pending_cb(Oid relid)
+{
+	pgstat_drop_transactional(PGSTAT_KIND_VACUUM_RELATION, relid, InvalidOid);
+}
+
 void
 pgstat_relation_delete_pending_cb(PgStat_EntryRef *entry_ref)
 {
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
new file mode 100644
index 0000000000..5a625132dd
--- /dev/null
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -0,0 +1,126 @@
+/* -------------------------------------------------------------------------
+ *
+ * pgstat_vacuum.c
+ *	  Implementation of extended vacuum statistics.
+ *
+ * This file contains the implementation of extended vacuum statistics. It is
+ * kept separate from pgstat_relation.c and pgstat_database.c to reduce the
+ * memory footprint of the regular relation and database statistics: vacuum
+ * metrics require significantly more space per relation, so they live in their
+ * own PGSTAT_KIND_VACUUM_RELATION stats kind.
+ *
+ * Copyright (c) 2001-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/utils/activity/pgstat_vacuum.c
+ * -------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgstat.h"
+#include "utils/memutils.h"
+#include "utils/pgstat_internal.h"
+
+#define ACCUMULATE_SUBFIELD(substruct, field) (dst->substruct.field += src->substruct.field)
+
+/*
+ * Accumulate the per-table extended vacuum counters collected so far.
+ *
+ * Only the counters derived directly from the vacuum's own bookkeeping are
+ * summed here.  The buffer, WAL and timing counters (and the per-index
+ * counters) are accumulated by additional code added together with the
+ * helpers that gather them.
+ */
+static void
+pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
+										 PgStat_VacuumRelationCounts *src)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	if (dst->type == PGSTAT_EXTVAC_INVALID)
+		dst->type = src->type;
+
+	Assert(src->type != PGSTAT_EXTVAC_INVALID &&
+		   src->type != PGSTAT_EXTVAC_DB &&
+		   src->type == dst->type);
+
+	ACCUMULATE_SUBFIELD(common, tuples_deleted);
+
+	if (dst->type == PGSTAT_EXTVAC_TABLE)
+	{
+		ACCUMULATE_SUBFIELD(table, pages_scanned);
+		ACCUMULATE_SUBFIELD(table, pages_removed);
+		ACCUMULATE_SUBFIELD(table, tuples_frozen);
+		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
+	}
+	else if (dst->type == PGSTAT_EXTVAC_INDEX)
+	{
+		ACCUMULATE_SUBFIELD(index, pages_deleted);
+	}
+}
+
+/*
+ * Report that the relation was just vacuumed, accumulating its extended
+ * statistics into the per-relation entry.
+ */
+void
+pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
+							  PgStat_VacuumRelationCounts *params)
+{
+	PgStat_EntryRef *entry_ref;
+	PgStatShared_VacuumRelation *shtabentry;
+	Oid			dboid = (shared ? InvalidOid : MyDatabaseId);
+
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_VACUUM_RELATION,
+											dboid, tableoid, false);
+	shtabentry = (PgStatShared_VacuumRelation *) entry_ref->shared_stats;
+	pgstat_accumulate_extvac_stats_relations(&shtabentry->stats, params);
+	pgstat_unlock_entry(entry_ref);
+}
+
+/*
+ * Flush out pending per-relation extended vacuum stats for the entry.
+ *
+ * If nowait is true, this function returns false if the lock could not be
+ * acquired immediately, otherwise true is returned.
+ */
+bool
+pgstat_vacuum_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
+{
+	PgStatShared_VacuumRelation *shtabstats;
+	PgStat_RelationVacuumPending *pendingent;
+
+	pendingent = (PgStat_RelationVacuumPending *) entry_ref->pending;
+	shtabstats = (PgStatShared_VacuumRelation *) entry_ref->shared_stats;
+
+	/* Ignore entries that didn't accumulate any actual counts. */
+	if (pg_memory_is_all_zeros(pendingent,
+							   sizeof(PgStat_RelationVacuumPending)))
+		return true;
+
+	if (!pgstat_lock_entry(entry_ref, nowait))
+		return false;
+
+	pgstat_accumulate_extvac_stats_relations(&shtabstats->stats,
+											 &pendingent->counts);
+
+	pgstat_unlock_entry(entry_ref);
+
+	return true;
+}
+
+/*
+ * Support function for the SQL-callable pgstat* functions. Returns the vacuum
+ * collected statistics for one relation or NULL.
+ */
+PgStat_VacuumRelationCounts *
+pgstat_fetch_stat_vacuum_tabentry(Oid relid, Oid dbid)
+{
+	return (PgStat_VacuumRelationCounts *)
+		pgstat_fetch_entry(PGSTAT_KIND_VACUUM_RELATION, dbid, relid, NULL);
+}
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 6409987d66..d6927d945d 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2367,3 +2367,87 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 
 	PG_RETURN_BOOL(pgstat_have_entry(kind, dboid, objid));
 }
+
+/*
+ * Get the extended vacuum statistics for a heap table.
+ */
+Datum
+pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
+{
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 6
+
+	Oid			relid = PG_GETARG_OID(0);
+	PgStat_VacuumRelationCounts *extvacuum;
+	TupleDesc	tupdesc;
+	Datum		values[PG_STAT_GET_VACUUM_TABLES_STATS_COLS] = {0};
+	bool		nulls[PG_STAT_GET_VACUUM_TABLES_STATS_COLS] = {0};
+	int			i = 0;
+
+	/* Build a tuple descriptor for our result type */
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "return type must be a row type");
+
+	extvacuum = pgstat_fetch_stat_vacuum_tabentry(relid, MyDatabaseId);
+	if (!extvacuum)
+	{
+		/* Retry as a shared relation before giving up. */
+		extvacuum = pgstat_fetch_stat_vacuum_tabentry(relid, InvalidOid);
+		if (!extvacuum)
+		{
+			InitMaterializedSRF(fcinfo, 0);
+			PG_RETURN_VOID();
+		}
+	}
+
+	values[i++] = ObjectIdGetDatum(relid);
+	values[i++] = Int64GetDatum(extvacuum->table.pages_scanned);
+	values[i++] = Int64GetDatum(extvacuum->table.pages_removed);
+	values[i++] = Int64GetDatum(extvacuum->common.tuples_deleted);
+	values[i++] = Int64GetDatum(extvacuum->table.tuples_frozen);
+	values[i++] = Int64GetDatum(extvacuum->table.recently_dead_tuples);
+
+	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
+
+	/* Returns the record as Datum */
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+}
+
+/*
+ * Get the extended vacuum statistics for an index.
+ */
+Datum
+pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
+{
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 3
+
+	Oid			relid = PG_GETARG_OID(0);
+	PgStat_VacuumRelationCounts *extvacuum;
+	TupleDesc	tupdesc;
+	Datum		values[PG_STAT_GET_VACUUM_INDEX_STATS_COLS] = {0};
+	bool		nulls[PG_STAT_GET_VACUUM_INDEX_STATS_COLS] = {0};
+	int			i = 0;
+
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "return type must be a row type");
+
+	extvacuum = pgstat_fetch_stat_vacuum_tabentry(relid, MyDatabaseId);
+	if (!extvacuum)
+	{
+		extvacuum = pgstat_fetch_stat_vacuum_tabentry(relid, InvalidOid);
+		if (!extvacuum)
+		{
+			InitMaterializedSRF(fcinfo, 0);
+			PG_RETURN_VOID();
+		}
+	}
+
+	values[i++] = ObjectIdGetDatum(relid);
+
+	values[i++] = Int64GetDatum(extvacuum->index.pages_deleted);
+	values[i++] = Int64GetDatum(extvacuum->common.tuples_deleted);
+
+	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
+
+	/* Returns the record as Datum */
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+}
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index afaa058b04..bb8eb41394 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -3220,6 +3220,12 @@
   boot_val => 'false',
 },
 
+{ name => 'track_vacuum_statistics', type => 'bool', context => 'PGC_SUSET', group => 'STATS_CUMULATIVE',
+  short_desc => 'Collects vacuum statistics for vacuum activity.',
+  variable => 'pgstat_track_vacuum_statistics',
+  boot_val => 'false',
+},
+
 { name => 'track_wal_io_timing', type => 'bool', context => 'PGC_SUSET', group => 'STATS_CUMULATIVE',
   short_desc => 'Collects timing statistics for WAL I/O activity.',
   variable => 'track_wal_io_timing',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index ac38cddaaf..4cf28f0f2e 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -702,6 +702,7 @@
 #track_wal_io_timing = off
 #track_functions = none                 # none, pl, all
 #stats_fetch_consistency = cache        # cache, none, snapshot
+#track_vacuum_statistics = off
 
 
 # - Monitoring -
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 291b039859..6bc3bd909b 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12638,6 +12638,16 @@
   proargnames => '{pid,io_id,io_generation,state,operation,off,length,target,handle_data_len,raw_result,result,target_desc,f_sync,f_localmem,f_buffered}',
   prosrc => 'pg_get_aios', proacl => '{POSTGRES=X,pg_read_all_stats=X}' },
 
+{ oid => '8001',
+  descr => 'pg_stat_get_vacuum_tables returns vacuum stats values for table',
+  proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
+  proretset => 't',
+  proargtypes => 'oid',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples}',
+  prosrc => 'pg_stat_get_vacuum_tables' }
+
 # oid8 related functions
 { oid => '6436', descr => 'convert oid to oid8',
   proname => 'oid8', prorettype => 'oid8', proargtypes => 'oid',
@@ -12703,4 +12713,13 @@
   proname => 'pg_stat_get_frozen_page_marks_cleared', provolatile => 's',
   proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
   prosrc => 'pg_stat_get_frozen_page_marks_cleared' },
+{ oid => '8004',
+  descr => 'pg_stat_get_vacuum_indexes returns vacuum stats values for index',
+  proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
+  proretset => 't',
+  proargtypes => 'oid',
+  proallargtypes => '{oid,oid,int8,int8}',
+  proargmodes => '{i,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted}',
+  prosrc => 'pg_stat_get_vacuum_indexes' },
 ]
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index 7db36cf8ad..404e7d0297 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -118,6 +118,15 @@ typedef struct PgStat_BackendSubEntry
 	PgStat_Counter conflict_count[CONFLICT_NUM_TYPES];
 } PgStat_BackendSubEntry;
 
+/* Type of ExtVacReport */
+typedef enum ExtVacReportType
+{
+	PGSTAT_EXTVAC_INVALID = 0,
+	PGSTAT_EXTVAC_TABLE = 1,
+	PGSTAT_EXTVAC_INDEX = 2,
+	PGSTAT_EXTVAC_DB = 3,
+}			ExtVacReportType;
+
 /* ----------
  * PgStat_TableCounts			The actual per-table counts kept by a backend
  *
@@ -164,6 +173,66 @@ typedef struct PgStat_TableCounts
 	PgStat_Counter frozen_page_marks_cleared;
 } PgStat_TableCounts;
 
+typedef struct PgStat_CommonCounts
+{
+	/* tuples */
+	int64		tuples_deleted;
+}			PgStat_CommonCounts;
+
+/* ----------
+ *
+ * PgStat_VacuumRelationCounts
+ *
+ * Additional statistics of vacuum processing over a relation.  Counters that
+ * require sampling buffer/WAL/timing usage, and the per-index counters, are
+ * added to the common and per-type members later, together with the code that
+ * gathers them.
+ * ----------
+ */
+typedef struct PgStat_VacuumRelationCounts
+{
+	PgStat_CommonCounts common;
+
+	ExtVacReportType type;		/* heap, index, etc. */
+
+	/* ----------
+	 *
+	 * There are separate metrics of statistic for tables and indexes,
+	 * which collect during vacuum.
+	 * The union operator allows to combine these statistics
+	 * so that each metric is assigned to a specific class of collected statistics.
+	 * Such a combined structure was called per_type_stats.
+	 * The name of the structure itself is not used anywhere,
+	 * it exists only for understanding the code.
+	 * ----------
+	*/
+	union
+	{
+		struct
+		{
+			int64		tuples_frozen;	/* tuples frozen up by vacuum */
+			int64		recently_dead_tuples;	/* deleted tuples that are
+												 * still visible to some
+												 * transaction */
+			int64		pages_scanned;	/* heap pages examined (not skipped by
+										 * VM) */
+			int64		pages_removed;	/* heap pages removed by vacuum
+										 * "truncation" */
+		}			table;
+		struct
+		{
+			int64		pages_deleted;	/* number of pages deleted by vacuum */
+		}			index;
+	} /* per_type_stats */ ;
+}			PgStat_VacuumRelationCounts;
+
+typedef struct PgStat_VacuumRelationStatus
+{
+	Oid			id;				/* table's OID */
+	bool		shared;			/* is it a shared catalog? */
+	PgStat_VacuumRelationCounts counts; /* event counts to be sent */
+}			PgStat_VacuumRelationStatus;
+
 /* ----------
  * PgStat_TableStatus			Per-table status within a backend
  *
@@ -188,6 +257,12 @@ typedef struct PgStat_TableStatus
 	Relation	relation;		/* rel that is using this entry */
 } PgStat_TableStatus;
 
+typedef struct PgStat_RelationVacuumPending
+{
+	Oid			id;				/* table's OID */
+	PgStat_VacuumRelationCounts counts; /* event counts to be sent */
+}			PgStat_RelationVacuumPending;
+
 /* ----------
  * PgStat_TableXactStatus		Per-table, per-subtransaction status
  * ----------
@@ -838,6 +913,12 @@ extern int	pgstat_get_transactional_drops(bool isCommit, struct xl_xact_stats_it
 extern void pgstat_execute_transactional_drops(int ndrops, struct xl_xact_stats_item *items, bool is_redo);
 
 
+extern void pgstat_vacuum_relation_delete_pending_cb(Oid relid);
+extern void
+			pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
+										  PgStat_VacuumRelationCounts * params);
+extern PgStat_VacuumRelationCounts * pgstat_fetch_stat_vacuum_tabentry(Oid relid, Oid dbid);
+
 /*
  * Functions in pgstat_wal.c
  */
@@ -854,7 +935,7 @@ extern PgStat_WalStats *pgstat_fetch_stat_wal(void);
 extern PGDLLIMPORT bool pgstat_track_counts;
 extern PGDLLIMPORT int pgstat_track_functions;
 extern PGDLLIMPORT int pgstat_fetch_consistency;
-
+extern PGDLLIMPORT bool pgstat_track_vacuum_statistics;
 
 /*
  * Variables in pgstat_bgwriter.c
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index fe463faaf6..46a127af2b 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -507,6 +507,12 @@ typedef struct PgStatShared_Relation
 	PgStat_StatTabEntry stats;
 } PgStatShared_Relation;
 
+typedef struct PgStatShared_VacuumRelation
+{
+	PgStatShared_Common header;
+	PgStat_VacuumRelationCounts stats;
+}			PgStatShared_VacuumRelation;
+
 typedef struct PgStatShared_Function
 {
 	PgStatShared_Common header;
@@ -689,6 +695,8 @@ extern void *pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid,
 								bool *may_free);
 extern void pgstat_snapshot_fixed(PgStat_Kind kind);
 
+extern bool pgstat_vacuum_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+
 
 /*
  * Functions in pgstat_archiver.c
diff --git a/src/include/utils/pgstat_kind.h b/src/include/utils/pgstat_kind.h
index 2d78a02968..f92149066c 100644
--- a/src/include/utils/pgstat_kind.h
+++ b/src/include/utils/pgstat_kind.h
@@ -39,9 +39,10 @@
 #define PGSTAT_KIND_LOCK	11
 #define PGSTAT_KIND_SLRU	12
 #define PGSTAT_KIND_WAL	13
+#define PGSTAT_KIND_VACUUM_RELATION	14
 
 #define PGSTAT_KIND_BUILTIN_MIN PGSTAT_KIND_DATABASE
-#define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_WAL
+#define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_VACUUM_RELATION
 #define PGSTAT_KIND_BUILTIN_SIZE (PGSTAT_KIND_BUILTIN_MAX + 1)
 
 /* Custom stats kinds */
diff --git a/src/test/isolation/expected/vacuum-extending-in-repetable-read.out b/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
new file mode 100644
index 0000000000..a9d977c520
--- /dev/null
+++ b/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
@@ -0,0 +1,38 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin_repeatable_read s2_delete s2_vacuum s2_print_vacuum_stats_table s1_commit s2_vacuum s2_print_vacuum_stats_table
+step s1_begin_repeatable_read: 
+  BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+  SELECT count(*) FROM test_vacuum_stat_isolation;
+
+count
+-----
+   50
+(1 row)
+
+step s2_delete: DELETE FROM test_vacuum_stat_isolation WHERE id <= 10;
+step s2_vacuum: VACUUM test_vacuum_stat_isolation;
+step s2_print_vacuum_stats_table: 
+    SELECT
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    FROM pg_stat_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+
+relname                   |tuples_deleted|recently_dead_tuples
+--------------------------+--------------+--------------------
+test_vacuum_stat_isolation|             0|                  10
+(1 row)
+
+step s1_commit: COMMIT;
+step s2_vacuum: VACUUM test_vacuum_stat_isolation;
+step s2_print_vacuum_stats_table: 
+    SELECT
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    FROM pg_stat_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+
+relname                   |tuples_deleted|recently_dead_tuples
+--------------------------+--------------+--------------------
+test_vacuum_stat_isolation|            10|                  10
+(1 row)
+
diff --git a/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec b/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
new file mode 100644
index 0000000000..50b79715f3
--- /dev/null
+++ b/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
@@ -0,0 +1,53 @@
+# Test for recently_dead_tuples in pg_stat_vacuum_tables.
+#
+# A tuple deleted while an older snapshot can still see it is counted as
+# recently_dead_tuples, because VACUUM is not allowed to remove it yet; once
+# the older snapshot is gone, the next VACUUM removes it and counts it in
+# tuples_deleted instead.
+
+setup
+{
+    CREATE TABLE test_vacuum_stat_isolation (id int, ival int)
+      WITH (autovacuum_enabled = off);
+    INSERT INTO test_vacuum_stat_isolation
+      SELECT i, i FROM generate_series(1, 50) i;
+    SET track_vacuum_statistics TO 'on';
+}
+
+teardown
+{
+    DROP TABLE test_vacuum_stat_isolation CASCADE;
+    RESET track_vacuum_statistics;
+}
+
+# Reader holding an old snapshot, so deleted tuples stay recently dead:
+session s1
+setup		{ SET track_vacuum_statistics TO 'on'; }
+step s1_begin_repeatable_read
+{
+  BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+  SELECT count(*) FROM test_vacuum_stat_isolation;
+}
+step s1_commit			{ COMMIT; }
+
+# Performs the DML, the vacuums and prints the collected statistics:
+session s2
+setup		{ SET track_vacuum_statistics TO 'on'; }
+step s2_delete			{ DELETE FROM test_vacuum_stat_isolation WHERE id <= 10; }
+step s2_vacuum			{ VACUUM test_vacuum_stat_isolation; }
+step s2_print_vacuum_stats_table
+{
+    SELECT
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    FROM pg_stat_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+}
+
+permutation
+    s1_begin_repeatable_read
+    s2_delete
+    s2_vacuum
+    s2_print_vacuum_stats_table
+    s1_commit
+    s2_vacuum
+    s2_print_vacuum_stats_table
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 096e4f763f..40c6aca96e 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2421,6 +2421,31 @@ pg_stat_user_tables| SELECT relid,
     frozen_page_marks_cleared
    FROM pg_stat_all_tables
   WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text));
+pg_stat_vacuum_indexes| SELECT c.oid AS relid,
+    i.oid AS indexrelid,
+    n.nspname AS schemaname,
+    c.relname,
+    i.relname AS indexrelname,
+    s.pages_deleted,
+    s.tuples_deleted
+   FROM (((pg_class c
+     JOIN pg_index x ON ((c.oid = x.indrelid)))
+     JOIN pg_class i ON ((i.oid = x.indexrelid)))
+     LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted)
+  WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
+pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
+    c.relname,
+    s.relid,
+    s.pages_scanned,
+    s.pages_removed,
+    s.tuples_deleted,
+    s.tuples_frozen,
+    s.recently_dead_tuples
+   FROM (pg_class c
+     JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples)
+  WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
     wal_bytes,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
new file mode 100644
index 0000000000..c3079e3379
--- /dev/null
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -0,0 +1,87 @@
+--
+-- Extended vacuum statistics views (pg_stat_vacuum_tables, _indexes, _database)
+--
+SET track_vacuum_statistics = on;
+CREATE TABLE vacstat_t (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_t SELECT g, repeat('x', 20) FROM generate_series(1, 1000) g;
+DELETE FROM vacstat_t WHERE id % 2 = 0;
+VACUUM vacstat_t;
+-- core heap-page and dead-tuple metrics.  This VACUUM runs without concurrent
+-- activity: every deleted tuple is fully removable (recently_dead_tuples = 0),
+-- the surviving tuples are too fresh to be frozen (tuples_frozen = 0), and the
+-- interleaved deletions leave no trailing empty pages to truncate
+-- (pages_removed = 0).  The recently_dead_tuples non-zero path is covered by
+-- the vacuum-extending-in-repetable-read isolation test.
+SELECT pages_scanned > 0 AS pages_scanned,
+       pages_removed = 0 AS pages_removed,
+       tuples_deleted = 500 AS tuples_deleted,
+       tuples_frozen = 0 AS tuples_frozen,
+       recently_dead_tuples = 0 AS recently_dead_tuples
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ pages_scanned | pages_removed | tuples_deleted | tuples_frozen | recently_dead_tuples 
+---------------+---------------+----------------+---------------+----------------------
+ t             | t             | t              | t             | t
+(1 row)
+
+-- pages_removed path: deleting every tuple lets VACUUM truncate the now-empty
+-- trailing heap pages, so pages_removed advances.
+CREATE TABLE vacstat_trunc (id int)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_trunc SELECT generate_series(1, 10000);
+DELETE FROM vacstat_trunc;
+VACUUM vacstat_trunc;
+SELECT pages_removed > 0 AS pages_removed,
+       tuples_deleted = 10000 AS tuples_deleted
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_trunc';
+ pages_removed | tuples_deleted 
+---------------+----------------
+ t             | t
+(1 row)
+
+DROP TABLE vacstat_trunc;
+-- tuples_frozen path: an aggressive VACUUM (FREEZE) freezes all live tuples,
+-- so tuples_frozen advances.
+CREATE TABLE vacstat_freeze (x int)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_freeze SELECT generate_series(1, 1000);
+VACUUM (FREEZE) vacstat_freeze;
+SELECT tuples_frozen > 0 AS tuples_frozen
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_freeze';
+ tuples_frozen 
+---------------
+ t
+(1 row)
+
+DROP TABLE vacstat_freeze;
+-- per-index view: the primary key index is processed by the same VACUUM.
+-- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
+-- while every index entry for a removed heap tuple is deleted.
+SELECT indexrelname,
+       pages_deleted = 0 AS pages_deleted,
+       tuples_deleted = 500 AS tuples_deleted
+  FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
+  indexrelname  | pages_deleted | tuples_deleted 
+----------------+---------------+----------------
+ vacstat_t_pkey | t             | t
+(1 row)
+
+-- index page-deletion path: deleting a contiguous key range empties whole
+-- btree leaf pages, which VACUUM then deletes (pages_deleted > 0), and every
+-- removed index entry is counted (tuples_deleted).
+CREATE TABLE vacstat_idxdel (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_idxdel SELECT g, repeat('x', 20) FROM generate_series(1, 10000) g;
+VACUUM vacstat_idxdel;
+DELETE FROM vacstat_idxdel WHERE id <= 9000;
+VACUUM vacstat_idxdel;
+SELECT indexrelname,
+       pages_deleted > 0 AS pages_deleted,
+       tuples_deleted = 9000 AS tuples_deleted
+  FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_idxdel' ORDER BY indexrelname;
+    indexrelname     | pages_deleted | tuples_deleted 
+---------------------+---------------+----------------
+ vacstat_idxdel_pkey | t             | t
+(1 row)
+
+DROP TABLE vacstat_idxdel;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47f..3c73207a9f 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -91,6 +91,9 @@ test: select_parallel
 test: write_parallel
 test: vacuum_parallel
 
+# extended vacuum statistics views
+test: vacuum_stats
+
 # Run this alone, because concurrent DROP TABLE would make non-superuser
 # "ANALYZE;" fail with "relation with OID $n does not exist".
 test: maintain_every
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
new file mode 100644
index 0000000000..04696ed824
--- /dev/null
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -0,0 +1,68 @@
+--
+-- Extended vacuum statistics views (pg_stat_vacuum_tables, _indexes, _database)
+--
+SET track_vacuum_statistics = on;
+
+CREATE TABLE vacstat_t (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_t SELECT g, repeat('x', 20) FROM generate_series(1, 1000) g;
+DELETE FROM vacstat_t WHERE id % 2 = 0;
+VACUUM vacstat_t;
+
+-- core heap-page and dead-tuple metrics.  This VACUUM runs without concurrent
+-- activity: every deleted tuple is fully removable (recently_dead_tuples = 0),
+-- the surviving tuples are too fresh to be frozen (tuples_frozen = 0), and the
+-- interleaved deletions leave no trailing empty pages to truncate
+-- (pages_removed = 0).  The recently_dead_tuples non-zero path is covered by
+-- the vacuum-extending-in-repetable-read isolation test.
+SELECT pages_scanned > 0 AS pages_scanned,
+       pages_removed = 0 AS pages_removed,
+       tuples_deleted = 500 AS tuples_deleted,
+       tuples_frozen = 0 AS tuples_frozen,
+       recently_dead_tuples = 0 AS recently_dead_tuples
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
+-- pages_removed path: deleting every tuple lets VACUUM truncate the now-empty
+-- trailing heap pages, so pages_removed advances.
+CREATE TABLE vacstat_trunc (id int)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_trunc SELECT generate_series(1, 10000);
+DELETE FROM vacstat_trunc;
+VACUUM vacstat_trunc;
+SELECT pages_removed > 0 AS pages_removed,
+       tuples_deleted = 10000 AS tuples_deleted
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_trunc';
+DROP TABLE vacstat_trunc;
+
+-- tuples_frozen path: an aggressive VACUUM (FREEZE) freezes all live tuples,
+-- so tuples_frozen advances.
+CREATE TABLE vacstat_freeze (x int)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_freeze SELECT generate_series(1, 1000);
+VACUUM (FREEZE) vacstat_freeze;
+SELECT tuples_frozen > 0 AS tuples_frozen
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_freeze';
+DROP TABLE vacstat_freeze;
+
+-- per-index view: the primary key index is processed by the same VACUUM.
+-- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
+-- while every index entry for a removed heap tuple is deleted.
+SELECT indexrelname,
+       pages_deleted = 0 AS pages_deleted,
+       tuples_deleted = 500 AS tuples_deleted
+  FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
+
+-- index page-deletion path: deleting a contiguous key range empties whole
+-- btree leaf pages, which VACUUM then deletes (pages_deleted > 0), and every
+-- removed index entry is counted (tuples_deleted).
+CREATE TABLE vacstat_idxdel (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_idxdel SELECT g, repeat('x', 20) FROM generate_series(1, 10000) g;
+VACUUM vacstat_idxdel;
+DELETE FROM vacstat_idxdel WHERE id <= 9000;
+VACUUM vacstat_idxdel;
+SELECT indexrelname,
+       pages_deleted > 0 AS pages_deleted,
+       tuples_deleted = 9000 AS tuples_deleted
+  FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_idxdel' ORDER BY indexrelname;
+DROP TABLE vacstat_idxdel;
-- 
2.39.5 (Apple Git-154)


From 3abb75ddc121acec0970c7f469417f0ef637dfa2 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:41:24 +0300
Subject: [PATCH 3/8] Extended vacuum statistics: missed dead tuples and pages
 for tables

Expose the counters for dead tuples that VACUUM could not remove because it
failed to acquire a cleanup lock on their heap page, with documentation and
regression coverage:

  missed_dead_tuples  dead tuples skipped because their page could not be
                      cleanup-locked
  missed_dead_pages   heap pages that contained such skipped dead tuples
---
 doc/src/sgml/system-views.sgml                | 16 +++++++
 src/backend/access/heap/vacuumlazy.c          |  2 +
 src/backend/catalog/system_views.sql          |  4 +-
 src/backend/utils/activity/pgstat_vacuum.c    |  2 +
 src/backend/utils/adt/pgstatfuncs.c           |  4 +-
 src/include/catalog/pg_proc.dat               |  6 +--
 src/include/pgstat.h                          |  4 ++
 .../vacuum-extending-in-repetable-read.out    | 46 +++++++++++++++----
 .../vacuum-extending-in-repetable-read.spec   | 28 +++++++++--
 src/test/regress/expected/rules.out           |  6 ++-
 src/test/regress/expected/vacuum_stats.out    | 12 +++++
 src/test/regress/sql/vacuum_stats.sql         |  8 ++++
 12 files changed, 116 insertions(+), 22 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index bdcd8d9f47..b96c653929 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5876,6 +5876,22 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of dead tuples that are still visible to some transaction and therefore could not yet be removed.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>missed_dead_pages</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages containing dead tuples that the vacuum could not remove because it failed to acquire a cleanup lock.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>missed_dead_tuples</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of dead tuples that the vacuum could not remove because it failed to acquire a cleanup lock on their page.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 3c403e86f0..7abc83dbfd 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -631,6 +631,8 @@ accumulate_heap_vacuum_statistics(LVRelState *vacrel,
 	extVacStats->common.tuples_deleted = vacrel->tuples_deleted;
 	extVacStats->table.tuples_frozen = vacrel->tuples_frozen;
 	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
+	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
+	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
 }
 
 /*
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 01487f1665..6f697ab390 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1570,7 +1570,9 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.pages_removed AS pages_removed,
         S.tuples_deleted AS tuples_deleted,
         S.tuples_frozen AS tuples_frozen,
-        S.recently_dead_tuples AS recently_dead_tuples
+        S.recently_dead_tuples AS recently_dead_tuples,
+        S.missed_dead_pages AS missed_dead_pages,
+        S.missed_dead_tuples AS missed_dead_tuples
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 5a625132dd..8cc505ea86 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -54,6 +54,8 @@ pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
 		ACCUMULATE_SUBFIELD(table, pages_removed);
 		ACCUMULATE_SUBFIELD(table, tuples_frozen);
 		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
+		ACCUMULATE_SUBFIELD(table, missed_dead_pages);
+		ACCUMULATE_SUBFIELD(table, missed_dead_tuples);
 	}
 	else if (dst->type == PGSTAT_EXTVAC_INDEX)
 	{
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index d6927d945d..742f4974d5 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 6
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 8
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2405,6 +2405,8 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.tuples_deleted);
 	values[i++] = Int64GetDatum(extvacuum->table.tuples_frozen);
 	values[i++] = Int64GetDatum(extvacuum->table.recently_dead_tuples);
+	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_pages);
+	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_tuples);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 6bc3bd909b..6d683413a4 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index 404e7d0297..bdcf758441 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -218,6 +218,10 @@ typedef struct PgStat_VacuumRelationCounts
 										 * VM) */
 			int64		pages_removed;	/* heap pages removed by vacuum
 										 * "truncation" */
+			int64		missed_dead_pages;	/* pages with missed dead tuples */
+			int64		missed_dead_tuples; /* tuples not pruned by vacuum due
+											 * to failure to get a cleanup
+											 * lock */
 		}			table;
 		struct
 		{
diff --git a/src/test/isolation/expected/vacuum-extending-in-repetable-read.out b/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
index a9d977c520..3eb038b9ad 100644
--- a/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
+++ b/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
@@ -1,6 +1,6 @@
-Parsed test spec with 2 sessions
+Parsed test spec with 3 sessions
 
-starting permutation: s1_begin_repeatable_read s2_delete s2_vacuum s2_print_vacuum_stats_table s1_commit s2_vacuum s2_print_vacuum_stats_table
+starting permutation: s1_begin_repeatable_read s2_delete s2_vacuum s2_print_vacuum_stats_table s1_commit pinholder_cursor s2_vacuum s2_print_vacuum_stats_table pinholder_commit s2_vacuum s2_print_vacuum_stats_table
 step s1_begin_repeatable_read: 
   BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
   SELECT count(*) FROM test_vacuum_stat_isolation;
@@ -14,25 +14,51 @@ step s2_delete: DELETE FROM test_vacuum_stat_isolation WHERE id <= 10;
 step s2_vacuum: VACUUM test_vacuum_stat_isolation;
 step s2_print_vacuum_stats_table: 
     SELECT
-    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples,
+    vt.missed_dead_tuples, vt.missed_dead_pages
     FROM pg_stat_vacuum_tables vt, pg_class c
     WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
 
-relname                   |tuples_deleted|recently_dead_tuples
---------------------------+--------------+--------------------
-test_vacuum_stat_isolation|             0|                  10
+relname                   |tuples_deleted|recently_dead_tuples|missed_dead_tuples|missed_dead_pages
+--------------------------+--------------+--------------------+------------------+-----------------
+test_vacuum_stat_isolation|             0|                  10|                 0|                0
 (1 row)
 
 step s1_commit: COMMIT;
+step pinholder_cursor: 
+  BEGIN;
+  DECLARE c CURSOR FOR SELECT 1 AS dummy FROM test_vacuum_stat_isolation;
+  FETCH NEXT FROM c;
+
+dummy
+-----
+    1
+(1 row)
+
+step s2_vacuum: VACUUM test_vacuum_stat_isolation;
+step s2_print_vacuum_stats_table: 
+    SELECT
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples,
+    vt.missed_dead_tuples, vt.missed_dead_pages
+    FROM pg_stat_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+
+relname                   |tuples_deleted|recently_dead_tuples|missed_dead_tuples|missed_dead_pages
+--------------------------+--------------+--------------------+------------------+-----------------
+test_vacuum_stat_isolation|             0|                  10|                10|                1
+(1 row)
+
+step pinholder_commit: COMMIT;
 step s2_vacuum: VACUUM test_vacuum_stat_isolation;
 step s2_print_vacuum_stats_table: 
     SELECT
-    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples,
+    vt.missed_dead_tuples, vt.missed_dead_pages
     FROM pg_stat_vacuum_tables vt, pg_class c
     WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
 
-relname                   |tuples_deleted|recently_dead_tuples
---------------------------+--------------+--------------------
-test_vacuum_stat_isolation|            10|                  10
+relname                   |tuples_deleted|recently_dead_tuples|missed_dead_tuples|missed_dead_pages
+--------------------------+--------------+--------------------+------------------+-----------------
+test_vacuum_stat_isolation|            10|                  10|                10|                1
 (1 row)
 
diff --git a/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec b/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
index 50b79715f3..334846193d 100644
--- a/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
+++ b/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
@@ -1,9 +1,11 @@
-# Test for recently_dead_tuples in pg_stat_vacuum_tables.
+# Test for recently_dead_tuples and missed_dead_tuples/missed_dead_pages in
+# pg_stat_vacuum_tables.
 #
 # A tuple deleted while an older snapshot can still see it is counted as
-# recently_dead_tuples, because VACUUM is not allowed to remove it yet; once
-# the older snapshot is gone, the next VACUUM removes it and counts it in
-# tuples_deleted instead.
+# recently_dead_tuples, because VACUUM is not allowed to remove it yet.  Once
+# the tuple becomes removable but VACUUM cannot acquire a cleanup lock on its
+# heap page (because another session pins the page), it is counted instead as
+# missed_dead_tuples, and its heap page as missed_dead_pages.
 
 setup
 {
@@ -30,6 +32,17 @@ step s1_begin_repeatable_read
 }
 step s1_commit			{ COMMIT; }
 
+# Holds a pin on the table's single heap page, so VACUUM cannot get a
+# cleanup lock on it:
+session pinholder
+step pinholder_cursor
+{
+  BEGIN;
+  DECLARE c CURSOR FOR SELECT 1 AS dummy FROM test_vacuum_stat_isolation;
+  FETCH NEXT FROM c;
+}
+step pinholder_commit	{ COMMIT; }
+
 # Performs the DML, the vacuums and prints the collected statistics:
 session s2
 setup		{ SET track_vacuum_statistics TO 'on'; }
@@ -38,7 +51,8 @@ step s2_vacuum			{ VACUUM test_vacuum_stat_isolation; }
 step s2_print_vacuum_stats_table
 {
     SELECT
-    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples,
+    vt.missed_dead_tuples, vt.missed_dead_pages
     FROM pg_stat_vacuum_tables vt, pg_class c
     WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
 }
@@ -49,5 +63,9 @@ permutation
     s2_vacuum
     s2_print_vacuum_stats_table
     s1_commit
+    pinholder_cursor
+    s2_vacuum
+    s2_print_vacuum_stats_table
+    pinholder_commit
     s2_vacuum
     s2_print_vacuum_stats_table
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 40c6aca96e..a2b0472a2d 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2441,10 +2441,12 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.pages_removed,
     s.tuples_deleted,
     s.tuples_frozen,
-    s.recently_dead_tuples
+    s.recently_dead_tuples,
+    s.missed_dead_pages,
+    s.missed_dead_tuples
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index c3079e3379..f342f71c57 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -54,6 +54,18 @@ SELECT tuples_frozen > 0 AS tuples_frozen
 (1 row)
 
 DROP TABLE vacstat_freeze;
+-- dead tuples/pages that could not be removed because the page was pinned by
+-- another backend (cleanup lock not acquired); none here, since this VACUUM
+-- runs without concurrent activity.  The non-zero path is covered by the
+-- vacuum-extending-in-repetable-read isolation test.
+SELECT missed_dead_pages = 0 AS missed_dead_pages,
+       missed_dead_tuples = 0 AS missed_dead_tuples
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ missed_dead_pages | missed_dead_tuples 
+-------------------+--------------------
+ t                 | t
+(1 row)
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index 04696ed824..b6f0f55af0 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -44,6 +44,14 @@ SELECT tuples_frozen > 0 AS tuples_frozen
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_freeze';
 DROP TABLE vacstat_freeze;
 
+-- dead tuples/pages that could not be removed because the page was pinned by
+-- another backend (cleanup lock not acquired); none here, since this VACUUM
+-- runs without concurrent activity.  The non-zero path is covered by the
+-- vacuum-extending-in-repetable-read isolation test.
+SELECT missed_dead_pages = 0 AS missed_dead_pages,
+       missed_dead_tuples = 0 AS missed_dead_tuples
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
-- 
2.39.5 (Apple Git-154)


From 7acaea3543f0276bce73ac5fd9208744035ea2a5 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:48:17 +0300
Subject: [PATCH 4/8] Extended vacuum statistics: visibility-map page
 transitions for tables

Expose the counters that track how the vacuum updated the visibility map of
the table, with documentation and regression coverage:

  vm_new_frozen_pages          pages newly marked all-frozen in the VM
  vm_new_visible_pages         pages newly marked all-visible in the VM
  vm_new_visible_frozen_pages  pages newly marked all-visible and all-frozen
---
 doc/src/sgml/system-views.sgml             | 24 ++++++++++++++++
 src/backend/access/heap/vacuumlazy.c       |  3 ++
 src/backend/catalog/system_views.sql       |  5 +++-
 src/backend/utils/activity/pgstat_vacuum.c |  3 ++
 src/backend/utils/adt/pgstatfuncs.c        |  5 +++-
 src/include/catalog/pg_proc.dat            |  6 ++--
 src/include/pgstat.h                       |  7 +++++
 src/test/regress/expected/rules.out        |  7 +++--
 src/test/regress/expected/vacuum_stats.out | 33 ++++++++++++++++++++++
 src/test/regress/sql/vacuum_stats.sql      | 25 ++++++++++++++++
 10 files changed, 111 insertions(+), 7 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index b96c653929..704d9e85eb 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5892,6 +5892,30 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of dead tuples that the vacuum could not remove because it failed to acquire a cleanup lock on their page.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_frozen_pages</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages newly marked all-frozen in the visibility map by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_visible_pages</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages newly marked all-visible in the visibility map by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_visible_frozen_pages</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages newly marked both all-visible and all-frozen in the visibility map by the vacuum.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 7abc83dbfd..91b6d9a9d9 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -633,6 +633,9 @@ accumulate_heap_vacuum_statistics(LVRelState *vacrel,
 	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
 	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
 	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
+	extVacStats->table.vm_new_frozen_pages = vacrel->new_all_frozen_pages;
+	extVacStats->table.vm_new_visible_pages = vacrel->new_all_visible_pages;
+	extVacStats->table.vm_new_visible_frozen_pages = vacrel->new_all_visible_all_frozen_pages;
 }
 
 /*
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 6f697ab390..21fa841f5a 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1572,7 +1572,10 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.tuples_frozen AS tuples_frozen,
         S.recently_dead_tuples AS recently_dead_tuples,
         S.missed_dead_pages AS missed_dead_pages,
-        S.missed_dead_tuples AS missed_dead_tuples
+        S.missed_dead_tuples AS missed_dead_tuples,
+        S.vm_new_frozen_pages AS vm_new_frozen_pages,
+        S.vm_new_visible_pages AS vm_new_visible_pages,
+        S.vm_new_visible_frozen_pages AS vm_new_visible_frozen_pages
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 8cc505ea86..4f6cbfd540 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -56,6 +56,9 @@ pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
 		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
 		ACCUMULATE_SUBFIELD(table, missed_dead_pages);
 		ACCUMULATE_SUBFIELD(table, missed_dead_tuples);
+		ACCUMULATE_SUBFIELD(table, vm_new_frozen_pages);
+		ACCUMULATE_SUBFIELD(table, vm_new_visible_pages);
+		ACCUMULATE_SUBFIELD(table, vm_new_visible_frozen_pages);
 	}
 	else if (dst->type == PGSTAT_EXTVAC_INDEX)
 	{
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 742f4974d5..3acf0a7391 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 8
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 11
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2407,6 +2407,9 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->table.recently_dead_tuples);
 	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_pages);
 	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_tuples);
+	values[i++] = Int64GetDatum(extvacuum->table.vm_new_frozen_pages);
+	values[i++] = Int64GetDatum(extvacuum->table.vm_new_visible_pages);
+	values[i++] = Int64GetDatum(extvacuum->table.vm_new_visible_frozen_pages);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 6d683413a4..7d87b03239 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index bdcf758441..f2bdef1463 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -222,6 +222,13 @@ typedef struct PgStat_VacuumRelationCounts
 			int64		missed_dead_tuples; /* tuples not pruned by vacuum due
 											 * to failure to get a cleanup
 											 * lock */
+			int64		vm_new_frozen_pages;	/* pages marked in VM as
+												 * frozen */
+			int64		vm_new_visible_pages;	/* pages marked in VM as
+												 * all-visible */
+			int64		vm_new_visible_frozen_pages;	/* pages marked in VM as
+														 * all-visible and
+														 * frozen */
 		}			table;
 		struct
 		{
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index a2b0472a2d..c30ff6c72f 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2443,10 +2443,13 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.tuples_frozen,
     s.recently_dead_tuples,
     s.missed_dead_pages,
-    s.missed_dead_tuples
+    s.missed_dead_tuples,
+    s.vm_new_frozen_pages,
+    s.vm_new_visible_pages,
+    s.vm_new_visible_frozen_pages
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index f342f71c57..0b3354cfff 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -66,6 +66,39 @@ SELECT missed_dead_pages = 0 AS missed_dead_pages,
  t                 | t
 (1 row)
 
+-- visibility-map page transitions.  Removing the interleaved dead tuples lets
+-- VACUUM mark every heap page all-visible (vm_new_visible_pages > 0).  Whether
+-- VACUUM also freezes those pages (vm_new_frozen_pages /
+-- vm_new_visible_frozen_pages) depends on opportunistic freezing, which is not
+-- deterministic here, so those are only checked for being non-negative; the
+-- positive freeze path is covered by the dedicated VACUUM (FREEZE) scenario
+-- below.
+SELECT vm_new_frozen_pages >= 0 AS vm_new_frozen_pages,
+       vm_new_visible_pages > 0 AS vm_new_visible_pages,
+       vm_new_visible_frozen_pages >= 0 AS vm_new_visible_frozen_pages
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ vm_new_frozen_pages | vm_new_visible_pages | vm_new_visible_frozen_pages 
+---------------------+----------------------+-----------------------------
+ t                   | t                    | t
+(1 row)
+
+-- freeze path: a dedicated VACUUM (FREEZE) marks freshly-loaded heap pages
+-- all-visible and all-frozen in one pass, so vm_new_visible_frozen_pages
+-- advances.  This restores the coverage of the former
+-- 053_vacuum_extending_freeze TAP test.
+CREATE TABLE vacstat_frz (x int)
+  WITH (autovacuum_enabled = off, fillfactor = 10);
+INSERT INTO vacstat_frz SELECT g FROM generate_series(1, 1000) g;
+VACUUM (FREEZE) vacstat_frz;
+SELECT vm_new_visible_pages > 0 AS vm_new_visible_pages,
+       vm_new_visible_frozen_pages > 0 AS vm_new_visible_frozen_pages
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_frz';
+ vm_new_visible_pages | vm_new_visible_frozen_pages 
+----------------------+-----------------------------
+ t                    | t
+(1 row)
+
+DROP TABLE vacstat_frz;
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index b6f0f55af0..db80ecf6a1 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -52,6 +52,31 @@ SELECT missed_dead_pages = 0 AS missed_dead_pages,
        missed_dead_tuples = 0 AS missed_dead_tuples
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
 
+-- visibility-map page transitions.  Removing the interleaved dead tuples lets
+-- VACUUM mark every heap page all-visible (vm_new_visible_pages > 0).  Whether
+-- VACUUM also freezes those pages (vm_new_frozen_pages /
+-- vm_new_visible_frozen_pages) depends on opportunistic freezing, which is not
+-- deterministic here, so those are only checked for being non-negative; the
+-- positive freeze path is covered by the dedicated VACUUM (FREEZE) scenario
+-- below.
+SELECT vm_new_frozen_pages >= 0 AS vm_new_frozen_pages,
+       vm_new_visible_pages > 0 AS vm_new_visible_pages,
+       vm_new_visible_frozen_pages >= 0 AS vm_new_visible_frozen_pages
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
+-- freeze path: a dedicated VACUUM (FREEZE) marks freshly-loaded heap pages
+-- all-visible and all-frozen in one pass, so vm_new_visible_frozen_pages
+-- advances.  This restores the coverage of the former
+-- 053_vacuum_extending_freeze TAP test.
+CREATE TABLE vacstat_frz (x int)
+  WITH (autovacuum_enabled = off, fillfactor = 10);
+INSERT INTO vacstat_frz SELECT g FROM generate_series(1, 1000) g;
+VACUUM (FREEZE) vacstat_frz;
+SELECT vm_new_visible_pages > 0 AS vm_new_visible_pages,
+       vm_new_visible_frozen_pages > 0 AS vm_new_visible_frozen_pages
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_frz';
+DROP TABLE vacstat_frz;
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
-- 
2.39.5 (Apple Git-154)


From 21ebf04efe2f19794161d460302d3c8491dd6732 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:51:25 +0300
Subject: [PATCH 5/8] Extended vacuum statistics: buffer/WAL/timing machinery
 and total buffer counters
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Introduce the resource-usage sampling machinery for extended vacuum
statistics, the database-wide aggregate, and the first counters that depend
on the sampling.

This commit introduces the resource-usage sampling that the buffer, WAL and
timing metrics rely on.  Around the processing of each relation, vacuum
snapshots WAL, buffer and timing usage and records the difference, so every
sampled metric reflects only that vacuum's work.  Index processing is sampled
on its own — including in parallel workers — and its usage is kept out of the
parent table's figures, so heap and index work are accounted separately.

The PGSTAT_KIND_VACUUM_DB stats kind is added here as well: it aggregates the
common (buffer/WAL/timing) counters of every relation vacuumed in the
database.  pgstat_accumulate_common() -- which sums those counters -- is
introduced together with it, since it is only meaningful once the sampling
exists.

This commit also creates the two remaining views, pg_stat_vacuum_indexes and
pg_stat_vacuum_database, alongside pg_stat_vacuum_tables.  They are introduced
here because they expose the common (buffer/WAL/timing) counters produced by
the sampling machinery; like the table view, they then grow column by column
in the following commits as each metric category is added.  The per-index view
also exposes the index-specific pages_deleted and tuples_deleted counters from
the start.

This commit exposes the shared-buffer access counters in all three views,
with documentation and regression coverage:

  total_blks_read     shared buffer blocks missed (read from disk)
  total_blks_hit      shared buffer blocks found in the buffer cache (hits)
  total_blks_dirtied  shared buffer blocks dirtied by this vacuum
  total_blks_written  shared buffer blocks written out

These counters are common to tables, indexes and the database aggregate
(where the first two are named db_blks_read/db_blks_hit); total_blks_dirtied
counts only the pages dirtied by this vacuum.
---
 doc/src/sgml/system-views.sgml               | 245 +++++++++++
 src/backend/access/heap/vacuumlazy.c         | 426 +++++++++++++++----
 src/backend/catalog/system_views.sql         |  28 +-
 src/backend/commands/dbcommands.c            |   1 +
 src/backend/commands/vacuum.c                |   4 +
 src/backend/commands/vacuumparallel.c        |   1 +
 src/backend/utils/activity/pgstat.c          |  15 +
 src/backend/utils/activity/pgstat_database.c |   9 +
 src/backend/utils/activity/pgstat_vacuum.c   | 106 ++++-
 src/backend/utils/adt/pgstatfuncs.c          |  53 ++-
 src/include/catalog/pg_proc.dat              |  21 +-
 src/include/commands/vacuum.h                |  29 ++
 src/include/pgstat.h                         |  16 +
 src/include/utils/pgstat_internal.h          |   7 +
 src/include/utils/pgstat_kind.h              |   3 +-
 src/test/regress/expected/rules.out          |  25 +-
 src/test/regress/expected/vacuum_stats.out   |  45 +-
 src/test/regress/sql/vacuum_stats.sql        |  31 +-
 18 files changed, 945 insertions(+), 120 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 704d9e85eb..088b532e5c 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5916,6 +5916,251 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of heap pages newly marked both all-visible and all-frozen in the visibility map by the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks missed (read from disk) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks found in the buffer cache (hits) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_dirtied</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks dirtied by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_written</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks written out by the vacuum.
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
+ <sect1 id="view-pg-stat-vacuum-indexes">
+  <title><structname>pg_stat_vacuum_indexes</structname></title>
+
+  <indexterm zone="view-pg-stat-vacuum-indexes">
+   <primary>pg_stat_vacuum_indexes</primary>
+  </indexterm>
+
+  <para>
+   The <structname>pg_stat_vacuum_indexes</structname> view will contain one row
+   for each index in the current database, showing extended statistics about
+   the activity of the most recent <command>VACUUM</command> on that index.
+   These statistics are accumulated only while
+   <varname>track_vacuum_statistics</varname> is enabled.
+  </para>
+
+  <table id="view-pg-stat-vacuum-indexes-cols">
+   <title><structname>pg_stat_vacuum_indexes</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the table the index is on.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>indexrelid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>schemaname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the schema that the table is in.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the table.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>indexrelname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of index pages deleted (made reusable) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of index entries removed by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks read while vacuuming the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer block hits while vacuuming the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_dirtied</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks dirtied while vacuuming the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_written</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks written out while vacuuming the index.
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
+ <sect1 id="view-pg-stat-vacuum-database">
+  <title><structname>pg_stat_vacuum_database</structname></title>
+
+  <indexterm zone="view-pg-stat-vacuum-database">
+   <primary>pg_stat_vacuum_database</primary>
+  </indexterm>
+
+  <para>
+   The <structname>pg_stat_vacuum_database</structname> view will contain one row
+   for each database, showing extended statistics aggregated over all
+   <command>VACUUM</command> activity in that database.  These statistics are
+   accumulated only while <varname>track_vacuum_statistics</varname> is enabled.
+  </para>
+
+  <table id="view-pg-stat-vacuum-database-cols">
+   <title><structname>pg_stat_vacuum_database</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>dboid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>dbname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>errors</structfield> <type>integer</type>
+      </para>
+      <para>
+       Number of vacuum operations in this database that failed with an error.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>db_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks read by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>db_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer block hits by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_dirtied</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks dirtied by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_written</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks written out by vacuum operations in this database.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 91b6d9a9d9..9c524909fe 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -411,6 +411,25 @@ typedef struct LVRelState
 	 * been permanently disabled.
 	 */
 	BlockNumber eager_scan_remaining_fails;
+
+	int32		wraparound_failsafe_count;	/* number of emergency vacuums to
+											 * prevent anti-wraparound
+											 * shutdown */
+
+	PgStat_VacuumRelationCounts extVacReportIdx;
+
+	/*
+	 * Per-index accumulated extended vacuum statistics.  VACUUM may process an
+	 * index several times (a bulkdelete pass per index scan plus a final
+	 * cleanup pass); we sum those passes here and report them to the
+	 * cumulative stats system exactly once per index at the end of the vacuum.
+	 * Only used by the non-parallel path -- parallel index vacuuming keeps the
+	 * equivalent running totals in the DSM and reports from
+	 * parallel_vacuum_end().  Sized vacrel->nindexes; NULL if there are no
+	 * indexes.
+	 */
+	PgStat_VacuumRelationCounts *extVacIdxReports;
+	bool	   *extVacIdxTouched;	/* was the matching index processed? */
 } LVRelState;
 
 
@@ -422,7 +441,6 @@ typedef struct LVSavedErrInfo
 	VacErrPhase phase;
 } LVSavedErrInfo;
 
-
 /* non-export function prototypes */
 static void lazy_scan_heap(LVRelState *vacrel);
 static void heap_vacuum_eager_scan_setup(LVRelState *vacrel,
@@ -452,12 +470,12 @@ static void lazy_cleanup_all_indexes(LVRelState *vacrel);
 static IndexBulkDeleteResult *lazy_vacuum_one_index(Relation indrel,
 													IndexBulkDeleteResult *istat,
 													double reltuples,
-													LVRelState *vacrel);
+													LVRelState *vacrel, int idx);
 static IndexBulkDeleteResult *lazy_cleanup_one_index(Relation indrel,
 													 IndexBulkDeleteResult *istat,
 													 double reltuples,
 													 bool estimated_count,
-													 LVRelState *vacrel);
+													 LVRelState *vacrel, int idx);
 static bool should_attempt_truncation(LVRelState *vacrel);
 static void lazy_truncate_heap(LVRelState *vacrel);
 static BlockNumber count_nondeletable_pages(LVRelState *vacrel,
@@ -484,9 +502,223 @@ static void update_vacuum_error_info(LVRelState *vacrel,
 									 OffsetNumber offnum);
 static void restore_vacuum_error_info(LVRelState *vacrel,
 									  const LVSavedErrInfo *saved_vacrel);
-static void accumulate_heap_vacuum_statistics(LVRelState *vacrel,
-											  PgStat_VacuumRelationCounts *extVacStats);
 
+/* ----------
+ * extvac_stats_start() -
+ *
+ * Save cut-off values of extended vacuum counters before start of a relation
+ * processing.
+ * ----------
+ */
+static void
+extvac_stats_start(Relation rel, LVExtStatCounters * counters)
+{
+	TimestampTz starttime;
+
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	memset(counters, 0, sizeof(LVExtStatCounters));
+
+	starttime = GetCurrentTimestamp();
+
+	counters->starttime = starttime;
+	counters->walusage = pgWalUsage;
+	counters->bufusage = pgBufferUsage;
+	counters->VacuumDelayTime = VacuumDelayTime;
+	counters->blocks_fetched = 0;
+	counters->blocks_hit = 0;
+
+	if (!rel->pgstat_info || !pgstat_track_counts)
+
+		/*
+		 * if something goes wrong or user doesn't want to track a database
+		 * activity - just suppress it.
+		 */
+		return;
+
+	counters->blocks_fetched = rel->pgstat_info->counts.blocks_fetched;
+	counters->blocks_hit = rel->pgstat_info->counts.blocks_hit;
+}
+
+/* ----------
+ * extvac_stats_end() -
+ *
+ *	Called to finish an extended vacuum statistic gathering and form a report.
+ * ----------
+ */
+static void
+extvac_stats_end(Relation rel, LVExtStatCounters * counters,
+				 PgStat_CommonCounts * report)
+{
+	BufferUsage bufusage;
+
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	memset(report, 0, sizeof(PgStat_CommonCounts));
+
+	/* Calculate diffs of global stat parameters on buffer usage. */
+	memset(&bufusage, 0, sizeof(BufferUsage));
+	BufferUsageAccumDiff(&bufusage, &pgBufferUsage, &counters->bufusage);
+
+	/*
+	 * Fill additional statistics on a vacuum processing operation.
+	 */
+	report->total_blks_read += bufusage.local_blks_read + bufusage.shared_blks_read;
+	report->total_blks_hit += bufusage.local_blks_hit + bufusage.shared_blks_hit;
+	report->total_blks_dirtied += bufusage.local_blks_dirtied + bufusage.shared_blks_dirtied;
+	report->total_blks_written += bufusage.shared_blks_written;
+}
+
+void
+extvac_stats_start_idx(Relation rel, IndexBulkDeleteResult *stats,
+					   LVExtStatCountersIdx * counters)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	/* Set initial values for common heap and index statistics */
+	extvac_stats_start(rel, &counters->common);
+	counters->pages_deleted = counters->tuples_removed = 0;
+
+	if (stats != NULL)
+	{
+		/*
+		 * XXX: Why do we need this code here? If it is needed, I feel lack of
+		 * comments, describing the reason.
+		 */
+		counters->tuples_removed = stats->tuples_removed;
+		counters->pages_deleted = stats->pages_deleted;
+	}
+}
+
+void
+extvac_stats_end_idx(Relation rel, IndexBulkDeleteResult *stats,
+					 LVExtStatCountersIdx * counters, PgStat_VacuumRelationCounts * report)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	memset(report, 0, sizeof(PgStat_VacuumRelationCounts));
+
+	extvac_stats_end(rel, &counters->common, &report->common);
+
+	report->type = PGSTAT_EXTVAC_INDEX;
+
+	if (stats != NULL)
+	{
+		/*
+		 * if something goes wrong or an user doesn't want to track a database
+		 * activity - just suppress it.
+		 */
+
+		/* Fill index-specific extended stats fields */
+		report->common.tuples_deleted =
+			stats->tuples_removed - counters->tuples_removed;
+		report->index.pages_deleted =
+			stats->pages_deleted - counters->pages_deleted;
+	}
+}
+
+/* Accumulate vacuum statistics for heap.
+ *
+  * Because of complexity of vacuum processing: it switch procesing between
+  * the heap relation to index relations and visa versa, we need to store
+  * gathered statistics information for heap relations several times before
+  * the vacuum starts processing the indexes again.
+  *
+  * It is necessary to gather correct statistics information for heap and indexes
+  * otherwice the index statistics information would be added to his parent heap
+  * statistics information and it would be difficult to analyze it later.
+  *
+  * We can't subtract union vacuum statistics information for index from the heap relations
+  * because of total and delay time time statistics collecting during parallel vacuum
+  * procudure.
+*/
+static void
+accumulate_heap_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCounts * extVacStats)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	/* Fill heap-specific extended stats fields */
+	extVacStats->type = PGSTAT_EXTVAC_TABLE;
+	extVacStats->table.pages_scanned = vacrel->scanned_pages;
+	extVacStats->table.pages_removed = vacrel->removed_pages;
+	extVacStats->table.vm_new_frozen_pages = vacrel->new_all_frozen_pages;
+	extVacStats->table.vm_new_visible_pages = vacrel->new_all_visible_pages;
+	extVacStats->table.vm_new_visible_frozen_pages = vacrel->new_all_visible_all_frozen_pages;
+	extVacStats->common.tuples_deleted = vacrel->tuples_deleted;
+	extVacStats->table.tuples_frozen = vacrel->tuples_frozen;
+	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
+	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
+	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
+
+	extVacStats->common.total_blks_dirtied -= vacrel->extVacReportIdx.common.total_blks_dirtied;
+	extVacStats->common.total_blks_hit -= vacrel->extVacReportIdx.common.total_blks_hit;
+	extVacStats->common.total_blks_read -= vacrel->extVacReportIdx.common.total_blks_read;
+	extVacStats->common.total_blks_written -= vacrel->extVacReportIdx.common.total_blks_written;
+}
+
+static void
+accumulate_idxs_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCounts * extVacIdxStats)
+{
+	/* Fill heap-specific extended stats fields */
+	vacrel->extVacReportIdx.common.total_blks_dirtied += extVacIdxStats->common.total_blks_dirtied;
+	vacrel->extVacReportIdx.common.total_blks_hit += extVacIdxStats->common.total_blks_hit;
+	vacrel->extVacReportIdx.common.total_blks_read += extVacIdxStats->common.total_blks_read;
+	vacrel->extVacReportIdx.common.total_blks_written += extVacIdxStats->common.total_blks_written;
+}
+
+/*
+ * Accumulate one index extended-vacuum report into a per-index running total.
+ *
+ * VACUUM may touch an index more than once: a bulkdelete pass for every index
+ * scan, plus a final cleanup pass.  Rather than reporting to the cumulative
+ * stats system on every pass, callers sum the passes here and report the
+ * totals exactly once per index at the end of the vacuum.  Shared between the
+ * non-parallel path (accumulating in vacrel) and the parallel path
+ * (accumulating in the DSM), hence not static.
+ */
+void
+extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
+							 const PgStat_VacuumRelationCounts * src)
+{
+	dst->type = PGSTAT_EXTVAC_INDEX;
+
+	dst->common.total_blks_read += src->common.total_blks_read;
+	dst->common.total_blks_hit += src->common.total_blks_hit;
+	dst->common.total_blks_dirtied += src->common.total_blks_dirtied;
+	dst->common.total_blks_written += src->common.total_blks_written;
+	dst->common.tuples_deleted += src->common.tuples_deleted;
+
+	dst->index.pages_deleted += src->index.pages_deleted;
+}
+
+/*
+ * Report the accumulated per-index extended vacuum statistics, one report per
+ * index.  Used by the non-parallel path only; the parallel path reports its
+ * DSM-resident totals from parallel_vacuum_end().
+ */
+static void
+report_index_vacuum_extstats(LVRelState *vacrel)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	for (int idx = 0; idx < vacrel->nindexes; idx++)
+	{
+		Relation	indrel = vacrel->indrels[idx];
+
+		if (!vacrel->extVacIdxTouched[idx])
+			continue;
+
+		pgstat_report_vacuum_extstats(RelationGetRelid(indrel),
+									  indrel->rd_rel->relisshared,
+									  &vacrel->extVacIdxReports[idx]);
+	}
+}
 
 
 /*
@@ -612,69 +844,6 @@ heap_vacuum_eager_scan_setup(LVRelState *vacrel, const VacuumParams *params)
 		first_region_ratio;
 }
 
-/*
- * Fill the extended vacuum statistics report for a heap relation with the
- * counters that are derived directly from the LVRelState.  Buffer, WAL and
- * timing counters require sampling resource usage around the vacuum and are
- * gathered separately; they are not touched here.
- */
-static void
-accumulate_heap_vacuum_statistics(LVRelState *vacrel,
-								  PgStat_VacuumRelationCounts *extVacStats)
-{
-	if (!pgstat_track_vacuum_statistics)
-		return;
-
-	extVacStats->type = PGSTAT_EXTVAC_TABLE;
-	extVacStats->table.pages_scanned = vacrel->scanned_pages;
-	extVacStats->table.pages_removed = vacrel->removed_pages;
-	extVacStats->common.tuples_deleted = vacrel->tuples_deleted;
-	extVacStats->table.tuples_frozen = vacrel->tuples_frozen;
-	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
-	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
-	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
-	extVacStats->table.vm_new_frozen_pages = vacrel->new_all_frozen_pages;
-	extVacStats->table.vm_new_visible_pages = vacrel->new_all_visible_pages;
-	extVacStats->table.vm_new_visible_frozen_pages = vacrel->new_all_visible_all_frozen_pages;
-}
-
-/*
- * Report the per-index extended vacuum statistics, one report per index.
- *
- * The per-index counters (pages_deleted and the number of removed index
- * entries) are derived directly from each index's final IndexBulkDeleteResult,
- * which already holds the totals accumulated across all bulkdelete and cleanup
- * passes -- so no per-pass sampling is needed here.  Used by the non-parallel
- * path only; the parallel path reports its DSM-resident results from
- * parallel_vacuum_end().
- */
-static void
-report_index_vacuum_extstats(LVRelState *vacrel)
-{
-	if (!pgstat_track_vacuum_statistics)
-		return;
-
-	for (int idx = 0; idx < vacrel->nindexes; idx++)
-	{
-		Relation	indrel = vacrel->indrels[idx];
-		IndexBulkDeleteResult *istat = vacrel->indstats[idx];
-		PgStat_VacuumRelationCounts report;
-
-		/* Skip indexes that this vacuum did not process */
-		if (istat == NULL)
-			continue;
-
-		memset(&report, 0, sizeof(report));
-		report.type = PGSTAT_EXTVAC_INDEX;
-		report.common.tuples_deleted = istat->tuples_removed;
-		report.index.pages_deleted = istat->pages_deleted;
-
-		pgstat_report_vacuum_extstats(RelationGetRelid(indrel),
-									  indrel->rd_rel->relisshared,
-									  &report);
-	}
-}
-
 /*
  *	heap_vacuum_rel() -- perform VACUUM for one heap relation
  *
@@ -701,7 +870,6 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 				new_rel_allvisible,
 				new_rel_allfrozen;
 	PGRUsage	ru0;
-	TimestampTz starttime = 0;
 	PgStat_Counter startreadtime = 0,
 				startwritetime = 0;
 	WalUsage	startwalusage = pgWalUsage;
@@ -709,9 +877,10 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	ErrorContextCallback errcallback;
 	char	  **indnames = NULL;
 	Size		dead_items_max_bytes = 0;
+	LVExtStatCounters extVacCounters;
 	PgStat_VacuumRelationCounts extVacReport;
 
-	/* Initialize the extended vacuum statistics report */
+	/* Initialize vacuum statistics */
 	memset(&extVacReport, 0, sizeof(PgStat_VacuumRelationCounts));
 
 	verbose = (params->options & VACOPT_VERBOSE) != 0;
@@ -727,8 +896,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 		}
 	}
 
-	/* Used for instrumentation and stats report */
-	starttime = GetCurrentTimestamp();
+	extvac_stats_start(rel, &extVacCounters);
 
 	pgstat_progress_start_command(PROGRESS_COMMAND_VACUUM,
 								  RelationGetRelid(rel));
@@ -756,8 +924,8 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel = palloc0_object(LVRelState);
 	vacrel->dbname = get_database_name(MyDatabaseId);
 	vacrel->relnamespace = get_namespace_name(RelationGetNamespace(rel));
-	vacrel->reloid = RelationGetRelid(rel);
 	vacrel->relname = pstrdup(RelationGetRelationName(rel));
+	vacrel->reloid = RelationGetRelid(rel);
 	vacrel->indname = NULL;
 	vacrel->phase = VACUUM_ERRCB_PHASE_UNKNOWN;
 	vacrel->verbose = verbose;
@@ -766,12 +934,26 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	errcallback.previous = error_context_stack;
 	error_context_stack = &errcallback;
 
+	memset(&vacrel->extVacReportIdx, 0, sizeof(PgStat_VacuumRelationCounts));
+	memset(&extVacReport.common, 0, sizeof(PgStat_CommonCounts));
+
 	/* Set up high level stuff about rel and its indexes */
 	vacrel->rel = rel;
 	vac_open_indexes(vacrel->rel, RowExclusiveLock, &vacrel->nindexes,
 					 &vacrel->indrels);
 	vacrel->bstrategy = bstrategy;
 
+	/*
+	 * Allocate per-index accumulators for extended vacuum statistics.  Index
+	 * passes add into these and the totals are reported once per index at the
+	 * end of the vacuum (see report_index_vacuum_extstats()).
+	 */
+	if (vacrel->nindexes > 0)
+	{
+		vacrel->extVacIdxReports =
+			palloc0_array(PgStat_VacuumRelationCounts, vacrel->nindexes);
+		vacrel->extVacIdxTouched = palloc0_array(bool, vacrel->nindexes);
+	}
 	if (instrument && vacrel->nindexes > 0)
 	{
 		/* Copy index names used by instrumentation (not error reporting) */
@@ -874,6 +1056,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel->aggressive = vacuum_get_cutoffs(rel, params, &vacrel->cutoffs);
 	vacrel->rel_pages = orig_rel_pages = RelationGetNumberOfBlocks(rel);
 	vacrel->vistest = GlobalVisTestFor(rel);
+	vacrel->wraparound_failsafe_count = 0;
 
 	/* Initialize state used to track oldest extant XID/MXID */
 	vacrel->NewRelfrozenXid = vacrel->cutoffs.OldestXmin;
@@ -1057,14 +1240,19 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	 * soon in cases where the failsafe prevented significant amounts of heap
 	 * vacuuming.
 	 */
+
+	/*
+	 * Make generic extended vacuum stats report and fill heap-specific
+	 * extended stats fields.
+	 */
+	extvac_stats_end(vacrel->rel, &extVacCounters, &extVacReport.common);
 	accumulate_heap_vacuum_statistics(vacrel, &extVacReport);
-	pgstat_report_vacuum_extstats(vacrel->reloid, rel->rd_rel->relisshared,
-								  &extVacReport);
+	pgstat_report_vacuum_extstats(vacrel->reloid, rel->rd_rel->relisshared, &extVacReport);
 	pgstat_report_vacuum(rel,
 						 Max(vacrel->new_live_tuples, 0),
 						 vacrel->recently_dead_tuples +
 						 vacrel->missed_dead_tuples,
-						 starttime);
+						 extVacCounters.starttime);
 	pgstat_progress_end_command();
 
 	if (instrument)
@@ -1072,7 +1260,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 		TimestampTz endtime = GetCurrentTimestamp();
 
 		if (verbose || params->log_vacuum_min_duration == 0 ||
-			TimestampDifferenceExceeds(starttime, endtime,
+			TimestampDifferenceExceeds(extVacCounters.starttime, endtime,
 									   params->log_vacuum_min_duration))
 		{
 			long		secs_dur;
@@ -1088,7 +1276,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 			int64		total_blks_read;
 			int64		total_blks_dirtied;
 
-			TimestampDifference(starttime, endtime, &secs_dur, &usecs_dur);
+			TimestampDifference(extVacCounters.starttime, endtime, &secs_dur, &usecs_dur);
 			memset(&walusage, 0, sizeof(WalUsage));
 			WalUsageAccumDiff(&walusage, &pgWalUsage, &startwalusage);
 			memset(&bufferusage, 0, sizeof(BufferUsage));
@@ -1698,7 +1886,8 @@ lazy_scan_heap(LVRelState *vacrel)
 	 * Report the per-index extended vacuum statistics accumulated over all
 	 * bulkdelete and cleanup passes, exactly once per index.  The parallel
 	 * path reports its DSM-resident totals from parallel_vacuum_end() instead,
-	 * so only do it here when index vacuuming ran in the leader.
+	 * so only do it here when index vacuuming ran in the leader.  pvs is still
+	 * alive at this point (it is torn down later in dead_items_cleanup()).
 	 */
 	if (vacrel->nindexes > 0 && !ParallelVacuumIsActive(vacrel))
 		report_index_vacuum_extstats(vacrel);
@@ -2619,7 +2808,7 @@ lazy_vacuum_all_indexes(LVRelState *vacrel)
 
 			vacrel->indstats[idx] = lazy_vacuum_one_index(indrel, istat,
 														  old_live_tuples,
-														  vacrel);
+														  vacrel, idx);
 
 			/* Report the number of indexes vacuumed */
 			pgstat_progress_update_param(PROGRESS_VACUUM_INDEXES_PROCESSED,
@@ -2635,11 +2824,21 @@ lazy_vacuum_all_indexes(LVRelState *vacrel)
 	}
 	else
 	{
+		LVExtStatCounters counters;
+		PgStat_VacuumRelationCounts extVacReport;
+
+		memset(&extVacReport.common, 0, sizeof(PgStat_CommonCounts));
+
+		extvac_stats_start(vacrel->rel, &counters);
+
 		/* Outsource everything to parallel variant */
 		parallel_vacuum_bulkdel_all_indexes(vacrel->pvs, old_live_tuples,
 											vacrel->num_index_scans,
 											&(vacrel->worker_usage.vacuum));
 
+		extvac_stats_end(vacrel->rel, &counters, &extVacReport.common);
+		accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+
 		/*
 		 * Do a postcheck to consider applying wraparound failsafe now.  Note
 		 * that parallel VACUUM only gets the precheck and this postcheck.
@@ -2987,6 +3186,7 @@ lazy_check_wraparound_failsafe(LVRelState *vacrel)
 		int64		progress_val[3] = {0, 0, PROGRESS_VACUUM_MODE_FAILSAFE};
 
 		VacuumFailsafeActive = true;
+		vacrel->wraparound_failsafe_count++;
 
 		/*
 		 * Abandon use of a buffer access strategy to allow use of all of
@@ -3060,7 +3260,7 @@ lazy_cleanup_all_indexes(LVRelState *vacrel)
 
 			vacrel->indstats[idx] =
 				lazy_cleanup_one_index(indrel, istat, reltuples,
-									   estimated_count, vacrel);
+									   estimated_count, vacrel, idx);
 
 			/* Report the number of indexes cleaned up */
 			pgstat_progress_update_param(PROGRESS_VACUUM_INDEXES_PROCESSED,
@@ -3069,11 +3269,21 @@ lazy_cleanup_all_indexes(LVRelState *vacrel)
 	}
 	else
 	{
+		LVExtStatCounters counters;
+		PgStat_VacuumRelationCounts extVacReport;
+
+		memset(&extVacReport.common, 0, sizeof(PgStat_CommonCounts));
+
+		extvac_stats_start(vacrel->rel, &counters);
+
 		/* Outsource everything to parallel variant */
 		parallel_vacuum_cleanup_all_indexes(vacrel->pvs, reltuples,
 											vacrel->num_index_scans,
 											estimated_count,
 											&(vacrel->worker_usage.cleanup));
+
+		extvac_stats_end(vacrel->rel, &counters, &extVacReport.common);
+		accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
 	}
 
 	/* Reset the progress counters */
@@ -3095,10 +3305,22 @@ lazy_cleanup_all_indexes(LVRelState *vacrel)
  */
 static IndexBulkDeleteResult *
 lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
-					  double reltuples, LVRelState *vacrel)
+					  double reltuples, LVRelState *vacrel, int idx)
 {
 	IndexVacuumInfo ivinfo;
 	LVSavedErrInfo saved_err_info;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
+
+	/*
+	 * Zero the report up front: extvac_stats_end_idx() leaves it untouched
+	 * when statistics tracking is disabled, but the accumulators below read it
+	 * unconditionally.
+	 */
+	memset(&extVacReport, 0, sizeof(PgStat_VacuumRelationCounts));
+
+	/* Set initial statistics values to gather vacuum statistics for the index */
+	extvac_stats_start_idx(indrel, istat, &extVacCounters);
 
 	ivinfo.index = indrel;
 	ivinfo.heaprel = vacrel->rel;
@@ -3125,6 +3347,19 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 	istat = vac_bulkdel_one_index(&ivinfo, istat, vacrel->dead_items,
 								  vacrel->dead_items_info);
 
+	/* Make extended vacuum stats report for index */
+	extvac_stats_end_idx(indrel, istat, &extVacCounters, &extVacReport);
+
+	/* This index's time must be excluded from the parent heap's totals */
+	accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+
+	/*
+	 * Accumulate this pass into the index's running totals.  They are reported
+	 * to the cumulative stats system once per index at the end of the vacuum.
+	 */
+	extvac_accumulate_idx_report(&vacrel->extVacIdxReports[idx], &extVacReport);
+	vacrel->extVacIdxTouched[idx] = true;
+
 	/* Revert to the previous phase information for error traceback */
 	restore_vacuum_error_info(vacrel, &saved_err_info);
 	pfree(vacrel->indname);
@@ -3145,10 +3380,22 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 static IndexBulkDeleteResult *
 lazy_cleanup_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 					   double reltuples, bool estimated_count,
-					   LVRelState *vacrel)
+					   LVRelState *vacrel, int idx)
 {
 	IndexVacuumInfo ivinfo;
 	LVSavedErrInfo saved_err_info;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
+
+	/*
+	 * Zero the report up front: extvac_stats_end_idx() leaves it untouched
+	 * when statistics tracking is disabled, but the accumulators below read it
+	 * unconditionally.
+	 */
+	memset(&extVacReport, 0, sizeof(PgStat_VacuumRelationCounts));
+
+	/* Set initial statistics values to gather vacuum statistics for the index */
+	extvac_stats_start_idx(indrel, istat, &extVacCounters);
 
 	ivinfo.index = indrel;
 	ivinfo.heaprel = vacrel->rel;
@@ -3174,6 +3421,19 @@ lazy_cleanup_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 
 	istat = vac_cleanup_one_index(&ivinfo, istat);
 
+	/* Make extended vacuum stats report for index */
+	extvac_stats_end_idx(indrel, istat, &extVacCounters, &extVacReport);
+
+	/* This index's time must be excluded from the parent heap's totals */
+	accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+
+	/*
+	 * Accumulate this pass into the index's running totals.  They are reported
+	 * to the cumulative stats system once per index at the end of the vacuum.
+	 */
+	extvac_accumulate_idx_report(&vacrel->extVacIdxReports[idx], &extVacReport);
+	vacrel->extVacIdxTouched[idx] = true;
+
 	/* Revert to the previous phase information for error traceback */
 	restore_vacuum_error_info(vacrel, &saved_err_info);
 	pfree(vacrel->indname);
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 21fa841f5a..b4fd8f90c6 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1575,7 +1575,11 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.missed_dead_tuples AS missed_dead_tuples,
         S.vm_new_frozen_pages AS vm_new_frozen_pages,
         S.vm_new_visible_pages AS vm_new_visible_pages,
-        S.vm_new_visible_frozen_pages AS vm_new_visible_frozen_pages
+        S.vm_new_visible_frozen_pages AS vm_new_visible_frozen_pages,
+        S.total_blks_read AS total_blks_read,
+        S.total_blks_hit AS total_blks_hit,
+        S.total_blks_dirtied AS total_blks_dirtied,
+        S.total_blks_written AS total_blks_written
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
@@ -1591,7 +1595,12 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             I.relname AS indexrelname,
 
             S.pages_deleted AS pages_deleted,
-            S.tuples_deleted AS tuples_deleted
+            S.tuples_deleted AS tuples_deleted,
+
+            S.total_blks_read AS total_blks_read,
+            S.total_blks_hit AS total_blks_hit,
+            S.total_blks_dirtied AS total_blks_dirtied,
+            S.total_blks_written AS total_blks_written
     FROM
             pg_class C JOIN
             pg_index X ON C.oid = X.indrelid JOIN
@@ -1599,3 +1608,18 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace),
             LATERAL pg_stat_get_vacuum_indexes(I.oid) S
     WHERE C.relkind IN ('r', 't', 'm');
+
+CREATE VIEW pg_stat_vacuum_database AS
+    SELECT
+            D.oid AS dboid,
+            D.datname AS dbname,
+
+            S.errors AS errors,
+
+            S.db_blks_read AS db_blks_read,
+            S.db_blks_hit AS db_blks_hit,
+            S.total_blks_dirtied AS total_blks_dirtied,
+            S.total_blks_written AS total_blks_written
+    FROM
+            pg_database D,
+            LATERAL pg_stat_get_vacuum_database(D.oid) S;
diff --git a/src/backend/commands/dbcommands.c b/src/backend/commands/dbcommands.c
index f0819d15ab..49c3b40c35 100644
--- a/src/backend/commands/dbcommands.c
+++ b/src/backend/commands/dbcommands.c
@@ -1830,6 +1830,7 @@ dropdb(const char *dbname, bool missing_ok, bool force)
 	 * Tell the cumulative stats system to forget it immediately, too.
 	 */
 	pgstat_drop_database(db_id);
+	pgstat_drop_vacuum_database(db_id);
 
 	/*
 	 * Except for the deletion of the catalog row, subsequent actions are not
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index a4abb29cf6..aacecee58a 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -118,6 +118,9 @@ pg_atomic_uint32 *VacuumSharedCostBalance = NULL;
 pg_atomic_uint32 *VacuumActiveNWorkers = NULL;
 int			VacuumCostBalanceLocal = 0;
 
+/* Cumulative storage to report total vacuum delay time. */
+double		VacuumDelayTime = 0;	/* msec. */
+
 /* non-export function prototypes */
 static List *expand_vacuum_rel(VacuumRelation *vrel,
 							   MemoryContext vac_context, int options);
@@ -2566,6 +2569,7 @@ vacuum_delay_point(bool is_analyze)
 			exit(1);
 
 		VacuumCostBalance = 0;
+		VacuumDelayTime += msec;
 
 		/*
 		 * Balance and update limit values for autovacuum workers. We must do
diff --git a/src/backend/commands/vacuumparallel.c b/src/backend/commands/vacuumparallel.c
index 7725c4ecc1..6c229564e0 100644
--- a/src/backend/commands/vacuumparallel.c
+++ b/src/backend/commands/vacuumparallel.c
@@ -1303,6 +1303,7 @@ parallel_vacuum_main(dsm_segment *seg, shm_toc *toc)
 		VacuumUpdateCosts();
 
 	VacuumCostBalance = 0;
+	VacuumDelayTime = 0;
 	VacuumCostBalanceLocal = 0;
 	VacuumSharedCostBalance = &(shared->cost_balance);
 	VacuumActiveNWorkers = &(shared->active_nworkers);
diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 4e950672e2..3741e4e54a 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -500,6 +500,21 @@ static const PgStat_KindInfo pgstat_kind_builtin_infos[PGSTAT_KIND_BUILTIN_SIZE]
 		.reset_all_cb = pgstat_wal_reset_all_cb,
 		.snapshot_cb = pgstat_wal_snapshot_cb,
 	},
+	[PGSTAT_KIND_VACUUM_DB] = {
+		.name = "vacuum statistics",
+
+		.fixed_amount = false,
+		.write_to_file = true,
+		/* so pg_stat_database entries can be seen in all databases */
+		.accessed_across_databases = true,
+
+		.shared_size = sizeof(PgStatShared_VacuumDB),
+		.shared_data_off = offsetof(PgStatShared_VacuumDB, stats),
+		.shared_data_len = sizeof(((PgStatShared_VacuumDB *) 0)->stats),
+		.pending_size = sizeof(PgStat_VacuumDBCounts),
+
+		.flush_pending_cb = pgstat_vacuum_db_flush_cb,
+	},
 	[PGSTAT_KIND_VACUUM_RELATION] = {
 		.name = "vacuum statistics",
 
diff --git a/src/backend/utils/activity/pgstat_database.c b/src/backend/utils/activity/pgstat_database.c
index 7f3bc01659..cdff619200 100644
--- a/src/backend/utils/activity/pgstat_database.c
+++ b/src/backend/utils/activity/pgstat_database.c
@@ -46,6 +46,15 @@ pgstat_drop_database(Oid databaseid)
 	pgstat_drop_transactional(PGSTAT_KIND_DATABASE, databaseid, InvalidOid);
 }
 
+/*
+ * Remove entry for the database being dropped.
+ */
+void
+pgstat_drop_vacuum_database(Oid databaseid)
+{
+	pgstat_drop_transactional(PGSTAT_KIND_VACUUM_DB, databaseid, InvalidOid);
+}
+
 /*
  * Called from autovacuum.c to report startup of an autovacuum process.
  * We are called before InitPostgres is done, so can't rely on MyDatabaseId;
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 4f6cbfd540..21678bf646 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -7,7 +7,7 @@
  * kept separate from pgstat_relation.c and pgstat_database.c to reduce the
  * memory footprint of the regular relation and database statistics: vacuum
  * metrics require significantly more space per relation, so they live in their
- * own PGSTAT_KIND_VACUUM_RELATION stats kind.
+ * own PGSTAT_KIND_VACUUM_RELATION and PGSTAT_KIND_VACUUM_DB stats kinds.
  *
  * Copyright (c) 2001-2026, PostgreSQL Global Development Group
  *
@@ -22,15 +22,32 @@
 #include "utils/memutils.h"
 #include "utils/pgstat_internal.h"
 
+/* ----------
+ * GUC parameters
+ * ----------
+ */
+bool		pgstat_track_vacuum_statistics_for_relations = false;
+
+#define ACCUMULATE_FIELD(field) (dst->field += src->field)
 #define ACCUMULATE_SUBFIELD(substruct, field) (dst->substruct.field += src->substruct.field)
 
 /*
- * Accumulate the per-table extended vacuum counters collected so far.
- *
- * Only the counters derived directly from the vacuum's own bookkeeping are
- * summed here.  The buffer, WAL and timing counters (and the per-index
- * counters) are accumulated by additional code added together with the
- * helpers that gather them.
+ * Accumulate the counters that are common to heap relations, indexes and
+ * databases.
+ */
+static void
+pgstat_accumulate_common(PgStat_CommonCounts *dst, const PgStat_CommonCounts *src)
+{
+	ACCUMULATE_FIELD(total_blks_read);
+	ACCUMULATE_FIELD(total_blks_hit);
+	ACCUMULATE_FIELD(total_blks_dirtied);
+	ACCUMULATE_FIELD(total_blks_written);
+
+	ACCUMULATE_FIELD(tuples_deleted);
+}
+
+/*
+ * Accumulate per-relation (heap or index) extended vacuum counters.
  */
 static void
 pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
@@ -46,19 +63,19 @@ pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
 		   src->type != PGSTAT_EXTVAC_DB &&
 		   src->type == dst->type);
 
-	ACCUMULATE_SUBFIELD(common, tuples_deleted);
+	pgstat_accumulate_common(&dst->common, &src->common);
 
 	if (dst->type == PGSTAT_EXTVAC_TABLE)
 	{
 		ACCUMULATE_SUBFIELD(table, pages_scanned);
 		ACCUMULATE_SUBFIELD(table, pages_removed);
-		ACCUMULATE_SUBFIELD(table, tuples_frozen);
-		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
-		ACCUMULATE_SUBFIELD(table, missed_dead_pages);
-		ACCUMULATE_SUBFIELD(table, missed_dead_tuples);
-		ACCUMULATE_SUBFIELD(table, vm_new_frozen_pages);
+				ACCUMULATE_SUBFIELD(table, vm_new_frozen_pages);
 		ACCUMULATE_SUBFIELD(table, vm_new_visible_pages);
 		ACCUMULATE_SUBFIELD(table, vm_new_visible_frozen_pages);
+		ACCUMULATE_SUBFIELD(table, missed_dead_pages);
+			ACCUMULATE_SUBFIELD(table, tuples_frozen);
+		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
+		ACCUMULATE_SUBFIELD(table, missed_dead_tuples);
 	}
 	else if (dst->type == PGSTAT_EXTVAC_INDEX)
 	{
@@ -67,8 +84,22 @@ pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
 }
 
 /*
- * Report that the relation was just vacuumed, accumulating its extended
- * statistics into the per-relation entry.
+ * Accumulate per-database extended vacuum counters.
+ */
+static void
+pgstat_accumulate_extvac_stats_db(PgStat_VacuumDBCounts *dst,
+								  PgStat_VacuumDBCounts *src)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	pgstat_accumulate_common(&dst->common, &src->common);
+	dst->errors += src->errors;
+}
+
+/*
+ * Report that the relation was just vacuumed, accumulating both its own
+ * extended statistics and the database-wide aggregate.
  */
 void
 pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
@@ -76,16 +107,25 @@ pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
 {
 	PgStat_EntryRef *entry_ref;
 	PgStatShared_VacuumRelation *shtabentry;
+	PgStatShared_VacuumDB *shdbentry;
 	Oid			dboid = (shared ? InvalidOid : MyDatabaseId);
 
 	if (!pgstat_track_vacuum_statistics)
 		return;
 
+	/* Per-relation extended vacuum statistics */
 	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_VACUUM_RELATION,
 											dboid, tableoid, false);
 	shtabentry = (PgStatShared_VacuumRelation *) entry_ref->shared_stats;
 	pgstat_accumulate_extvac_stats_relations(&shtabentry->stats, params);
 	pgstat_unlock_entry(entry_ref);
+
+	/* Database-wide aggregate of the same work */
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_VACUUM_DB,
+											dboid, InvalidOid, false);
+	shdbentry = (PgStatShared_VacuumDB *) entry_ref->shared_stats;
+	pgstat_accumulate_common(&shdbentry->stats.common, &params->common);
+	pgstat_unlock_entry(entry_ref);
 }
 
 /*
@@ -119,6 +159,31 @@ pgstat_vacuum_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	return true;
 }
 
+/*
+ * Flush out pending per-database extended vacuum stats for the entry.
+ */
+bool
+pgstat_vacuum_db_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
+{
+	PgStatShared_VacuumDB *sharedent;
+	PgStat_VacuumDBCounts *pendingent;
+
+	pendingent = (PgStat_VacuumDBCounts *) entry_ref->pending;
+	sharedent = (PgStatShared_VacuumDB *) entry_ref->shared_stats;
+
+	if (pg_memory_is_all_zeros(pendingent, sizeof(PgStat_VacuumDBCounts)))
+		return true;
+
+	if (!pgstat_lock_entry(entry_ref, nowait))
+		return false;
+
+	pgstat_accumulate_extvac_stats_db(&sharedent->stats, pendingent);
+
+	pgstat_unlock_entry(entry_ref);
+
+	return true;
+}
+
 /*
  * Support function for the SQL-callable pgstat* functions. Returns the vacuum
  * collected statistics for one relation or NULL.
@@ -129,3 +194,14 @@ pgstat_fetch_stat_vacuum_tabentry(Oid relid, Oid dbid)
 	return (PgStat_VacuumRelationCounts *)
 		pgstat_fetch_entry(PGSTAT_KIND_VACUUM_RELATION, dbid, relid, NULL);
 }
+
+/*
+ * Support function for the SQL-callable pgstat* functions. Returns the vacuum
+ * collected statistics for one database or NULL.
+ */
+PgStat_VacuumDBCounts *
+pgstat_fetch_stat_vacuum_dbentry(Oid dbid)
+{
+	return (PgStat_VacuumDBCounts *)
+		pgstat_fetch_entry(PGSTAT_KIND_VACUUM_DB, dbid, InvalidOid, NULL);
+}
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 3acf0a7391..fc32b20850 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 11
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 15
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2410,6 +2410,10 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->table.vm_new_frozen_pages);
 	values[i++] = Int64GetDatum(extvacuum->table.vm_new_visible_pages);
 	values[i++] = Int64GetDatum(extvacuum->table.vm_new_visible_frozen_pages);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_read);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
@@ -2423,7 +2427,7 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 3
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 7
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2451,8 +2455,53 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->index.pages_deleted);
 	values[i++] = Int64GetDatum(extvacuum->common.tuples_deleted);
 
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_read);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
+
 	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
 
 	/* Returns the record as Datum */
 	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
 }
+
+/*
+ * Get the extended vacuum statistics for a database.
+ */
+Datum
+pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
+{
+#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 6
+
+	Oid			dbid = PG_GETARG_OID(0);
+	PgStat_VacuumDBCounts *extvacuum;
+	TupleDesc	tupdesc;
+	Datum		values[PG_STAT_GET_VACUUM_DATABASE_STATS_COLS] = {0};
+	bool		nulls[PG_STAT_GET_VACUUM_DATABASE_STATS_COLS] = {0};
+	int			i = 0;
+
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "return type must be a row type");
+
+	extvacuum = OidIsValid(dbid) ? pgstat_fetch_stat_vacuum_dbentry(dbid) : NULL;
+	if (!extvacuum)
+	{
+		InitMaterializedSRF(fcinfo, 0);
+		PG_RETURN_VOID();
+	}
+
+	values[i++] = ObjectIdGetDatum(dbid);
+
+	values[i++] = Int32GetDatum(extvacuum->errors);
+
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_read);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
+
+	Assert(i == PG_STAT_GET_VACUUM_DATABASE_STATS_COLS);
+
+	/* Returns the record as Datum */
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+}
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 7d87b03239..edf3cc8b62 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
@@ -12718,8 +12718,17 @@
   proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8}',
-  proargmodes => '{i,o,o,o}',
-  proargnames => '{reloid,relid,pages_deleted,tuples_deleted}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written}',
   prosrc => 'pg_stat_get_vacuum_indexes' },
+{ oid => '8005',
+  descr => 'pg_stat_get_vacuum_database returns vacuum stats values for database',
+  proname => 'pg_stat_get_vacuum_database', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
+  proretset => 't',
+  proargtypes => 'oid',
+  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o}',
+  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written}',
+  prosrc => 'pg_stat_get_vacuum_database' },
 ]
diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h
index 956d9cea36..f0d08e1a64 100644
--- a/src/include/commands/vacuum.h
+++ b/src/include/commands/vacuum.h
@@ -21,9 +21,11 @@
 #include "catalog/pg_class.h"
 #include "catalog/pg_statistic.h"
 #include "catalog/pg_type.h"
+#include "executor/instrument.h"
 #include "parser/parse_node.h"
 #include "storage/buf.h"
 #include "utils/relcache.h"
+#include "pgstat.h"
 
 /*
  * Flags for amparallelvacuumoptions to control the participation of bulkdelete
@@ -321,6 +323,26 @@ typedef struct PVWorkerUsage
 	PVWorkerStats cleanup;
 } PVWorkerUsage;
 
+/*
+ * Counters and usage data for extended stats tracking.
+ */
+typedef struct LVExtStatCounters
+{
+	TimestampTz starttime;
+	WalUsage	walusage;
+	BufferUsage bufusage;
+	double		VacuumDelayTime;
+	PgStat_Counter blocks_fetched;
+	PgStat_Counter blocks_hit;
+}			LVExtStatCounters;
+
+typedef struct LVExtStatCountersIdx
+{
+	LVExtStatCounters common;
+	int64		pages_deleted;
+	int64		tuples_removed;
+}			LVExtStatCountersIdx;
+
 /* GUC parameters */
 extern PGDLLIMPORT int default_statistics_target;	/* PGDLLIMPORT for PostGIS */
 extern PGDLLIMPORT int vacuum_freeze_min_age;
@@ -353,6 +375,7 @@ extern PGDLLIMPORT double vacuum_max_eager_freeze_failure_rate;
 extern PGDLLIMPORT pg_atomic_uint32 *VacuumSharedCostBalance;
 extern PGDLLIMPORT pg_atomic_uint32 *VacuumActiveNWorkers;
 extern PGDLLIMPORT int VacuumCostBalanceLocal;
+extern PGDLLIMPORT double VacuumDelayTime;
 
 extern PGDLLIMPORT bool VacuumFailsafeActive;
 extern PGDLLIMPORT double vacuum_cost_delay;
@@ -439,4 +462,10 @@ extern double anl_random_fract(void);
 extern double anl_init_selection_state(int n);
 extern double anl_get_next_S(double t, int n, double *stateptr);
 
+extern void extvac_stats_start_idx(Relation rel, IndexBulkDeleteResult *stats,
+								   LVExtStatCountersIdx * counters);
+extern void extvac_stats_end_idx(Relation rel, IndexBulkDeleteResult *stats,
+								 LVExtStatCountersIdx * counters, PgStat_VacuumRelationCounts * report);
+extern void extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
+										 const PgStat_VacuumRelationCounts * src);
 #endif							/* VACUUM_H */
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index f2bdef1463..6a9c9800b7 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -175,6 +175,12 @@ typedef struct PgStat_TableCounts
 
 typedef struct PgStat_CommonCounts
 {
+	/* blocks */
+	int64		total_blks_read;
+	int64		total_blks_hit;
+	int64		total_blks_dirtied;
+	int64		total_blks_written;
+
 	/* tuples */
 	int64		tuples_deleted;
 }			PgStat_CommonCounts;
@@ -244,6 +250,13 @@ typedef struct PgStat_VacuumRelationStatus
 	PgStat_VacuumRelationCounts counts; /* event counts to be sent */
 }			PgStat_VacuumRelationStatus;
 
+typedef struct PgStat_VacuumDBCounts
+{
+	Oid			dbjid;
+	PgStat_CommonCounts common;
+	int32		errors;
+}			PgStat_VacuumDBCounts;
+
 /* ----------
  * PgStat_TableStatus			Per-table status within a backend
  *
@@ -924,11 +937,13 @@ extern int	pgstat_get_transactional_drops(bool isCommit, struct xl_xact_stats_it
 extern void pgstat_execute_transactional_drops(int ndrops, struct xl_xact_stats_item *items, bool is_redo);
 
 
+extern void pgstat_drop_vacuum_database(Oid databaseid);
 extern void pgstat_vacuum_relation_delete_pending_cb(Oid relid);
 extern void
 			pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
 										  PgStat_VacuumRelationCounts * params);
 extern PgStat_VacuumRelationCounts * pgstat_fetch_stat_vacuum_tabentry(Oid relid, Oid dbid);
+extern PgStat_VacuumDBCounts * pgstat_fetch_stat_vacuum_dbentry(Oid dbid);
 
 /*
  * Functions in pgstat_wal.c
@@ -947,6 +962,7 @@ extern PGDLLIMPORT bool pgstat_track_counts;
 extern PGDLLIMPORT int pgstat_track_functions;
 extern PGDLLIMPORT int pgstat_fetch_consistency;
 extern PGDLLIMPORT bool pgstat_track_vacuum_statistics;
+extern PGDLLIMPORT bool pgstat_track_vacuum_statistics_for_relations;
 
 /*
  * Variables in pgstat_bgwriter.c
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index 46a127af2b..a234c797e0 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -507,6 +507,12 @@ typedef struct PgStatShared_Relation
 	PgStat_StatTabEntry stats;
 } PgStatShared_Relation;
 
+typedef struct PgStatShared_VacuumDB
+{
+	PgStatShared_Common header;
+	PgStat_VacuumDBCounts stats;
+}			PgStatShared_VacuumDB;
+
 typedef struct PgStatShared_VacuumRelation
 {
 	PgStatShared_Common header;
@@ -695,6 +701,7 @@ extern void *pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid,
 								bool *may_free);
 extern void pgstat_snapshot_fixed(PgStat_Kind kind);
 
+bool		pgstat_vacuum_db_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
 extern bool pgstat_vacuum_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
 
 
diff --git a/src/include/utils/pgstat_kind.h b/src/include/utils/pgstat_kind.h
index f92149066c..52d9367fbe 100644
--- a/src/include/utils/pgstat_kind.h
+++ b/src/include/utils/pgstat_kind.h
@@ -40,9 +40,10 @@
 #define PGSTAT_KIND_SLRU	12
 #define PGSTAT_KIND_WAL	13
 #define PGSTAT_KIND_VACUUM_RELATION	14
+#define PGSTAT_KIND_VACUUM_DB	15
 
 #define PGSTAT_KIND_BUILTIN_MIN PGSTAT_KIND_DATABASE
-#define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_VACUUM_RELATION
+#define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_VACUUM_DB
 #define PGSTAT_KIND_BUILTIN_SIZE (PGSTAT_KIND_BUILTIN_MAX + 1)
 
 /* Custom stats kinds */
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index c30ff6c72f..7b67fe72a6 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2421,18 +2421,31 @@ pg_stat_user_tables| SELECT relid,
     frozen_page_marks_cleared
    FROM pg_stat_all_tables
   WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text));
+pg_stat_vacuum_database| SELECT d.oid AS dboid,
+    d.datname AS dbname,
+    s.errors,
+    s.db_blks_read,
+    s.db_blks_hit,
+    s.total_blks_dirtied,
+    s.total_blks_written
+   FROM pg_database d,
+    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written);
 pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     i.oid AS indexrelid,
     n.nspname AS schemaname,
     c.relname,
     i.relname AS indexrelname,
     s.pages_deleted,
-    s.tuples_deleted
+    s.tuples_deleted,
+    s.total_blks_read,
+    s.total_blks_hit,
+    s.total_blks_dirtied,
+    s.total_blks_written
    FROM (((pg_class c
      JOIN pg_index x ON ((c.oid = x.indrelid)))
      JOIN pg_class i ON ((i.oid = x.indexrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted)
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     c.relname,
@@ -2446,10 +2459,14 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.missed_dead_tuples,
     s.vm_new_frozen_pages,
     s.vm_new_visible_pages,
-    s.vm_new_visible_frozen_pages
+    s.vm_new_visible_frozen_pages,
+    s.total_blks_read,
+    s.total_blks_hit,
+    s.total_blks_dirtied,
+    s.total_blks_written
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index 0b3354cfff..6c46239eb6 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -99,16 +99,37 @@ SELECT vm_new_visible_pages > 0 AS vm_new_visible_pages,
 (1 row)
 
 DROP TABLE vacstat_frz;
+-- total buffer access counters.  The vacuum always touches the table's pages
+-- through the buffer cache (total_blks_hit > 0) and dirties some of them while
+-- removing dead tuples (total_blks_dirtied > 0).  total_blks_read and
+-- total_blks_written depend on the buffer-cache and checkpoint state at run
+-- time, so they are only checked for being non-negative.
+SELECT total_blks_read >= 0 AS total_blks_read,
+       total_blks_hit > 0 AS total_blks_hit,
+       total_blks_dirtied > 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written 
+-----------------+----------------+--------------------+--------------------
+ t               | t              | t                  | t
+(1 row)
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
--- while every index entry for a removed heap tuple is deleted.
+-- while every index entry for a removed heap tuple is deleted.  The index is
+-- read through the buffer cache (total_blks_hit > 0); the read/written/dirtied
+-- counters depend on run-time cache state.
 SELECT indexrelname,
        pages_deleted = 0 AS pages_deleted,
-       tuples_deleted = 500 AS tuples_deleted
+       tuples_deleted = 500 AS tuples_deleted,
+       total_blks_read >= 0 AS total_blks_read,
+       total_blks_hit > 0 AS total_blks_hit,
+       total_blks_dirtied >= 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
-  indexrelname  | pages_deleted | tuples_deleted 
-----------------+---------------+----------------
- vacstat_t_pkey | t             | t
+  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written 
+----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------
+ vacstat_t_pkey | t             | t              | t               | t              | t                  | t
 (1 row)
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -130,3 +151,17 @@ SELECT indexrelname,
 (1 row)
 
 DROP TABLE vacstat_idxdel;
+-- per-database aggregate view: no vacuum errors occurred in this database, and
+-- the vacuums in this database touched pages through the buffer cache
+-- (db_blks_hit > 0).
+SELECT errors = 0 AS errors,
+       db_blks_read >= 0 AS db_blks_read,
+       db_blks_hit > 0 AS db_blks_hit,
+       total_blks_dirtied >= 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
+  FROM pg_stat_vacuum_database WHERE dbname = current_database();
+ errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written 
+--------+--------------+-------------+--------------------+--------------------
+ t      | t            | t           | t                  | t
+(1 row)
+
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index db80ecf6a1..91079759ea 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -77,12 +77,29 @@ SELECT vm_new_visible_pages > 0 AS vm_new_visible_pages,
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_frz';
 DROP TABLE vacstat_frz;
 
+-- total buffer access counters.  The vacuum always touches the table's pages
+-- through the buffer cache (total_blks_hit > 0) and dirties some of them while
+-- removing dead tuples (total_blks_dirtied > 0).  total_blks_read and
+-- total_blks_written depend on the buffer-cache and checkpoint state at run
+-- time, so they are only checked for being non-negative.
+SELECT total_blks_read >= 0 AS total_blks_read,
+       total_blks_hit > 0 AS total_blks_hit,
+       total_blks_dirtied > 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
--- while every index entry for a removed heap tuple is deleted.
+-- while every index entry for a removed heap tuple is deleted.  The index is
+-- read through the buffer cache (total_blks_hit > 0); the read/written/dirtied
+-- counters depend on run-time cache state.
 SELECT indexrelname,
        pages_deleted = 0 AS pages_deleted,
-       tuples_deleted = 500 AS tuples_deleted
+       tuples_deleted = 500 AS tuples_deleted,
+       total_blks_read >= 0 AS total_blks_read,
+       total_blks_hit > 0 AS total_blks_hit,
+       total_blks_dirtied >= 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -99,3 +116,13 @@ SELECT indexrelname,
        tuples_deleted = 9000 AS tuples_deleted
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_idxdel' ORDER BY indexrelname;
 DROP TABLE vacstat_idxdel;
+
+-- per-database aggregate view: no vacuum errors occurred in this database, and
+-- the vacuums in this database touched pages through the buffer cache
+-- (db_blks_hit > 0).
+SELECT errors = 0 AS errors,
+       db_blks_read >= 0 AS db_blks_read,
+       db_blks_hit > 0 AS db_blks_hit,
+       total_blks_dirtied >= 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
+  FROM pg_stat_vacuum_database WHERE dbname = current_database();
-- 
2.39.5 (Apple Git-154)


From 791dacf1cc81105971add3e7dee45016c4bc7c79 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:52:11 +0300
Subject: [PATCH 6/8] Extended vacuum statistics: per-relation buffer access
 for tables and indexes

Expose the per-relation buffer access counters in pg_stat_vacuum_tables and
pg_stat_vacuum_indexes, with documentation and regression coverage:

  rel_blks_read  this relation's blocks read from disk by the vacuum
  rel_blks_hit   this relation's blocks found in shared buffers

Unlike total_blks_*, which count all shared-buffer access during the vacuum,
these are restricted to the target relation's own blocks.
---
 doc/src/sgml/system-views.sgml             | 32 ++++++++++++++++++++++
 src/backend/access/heap/vacuumlazy.c       | 15 ++++++++++
 src/backend/catalog/system_views.sql       |  9 ++++--
 src/backend/utils/activity/pgstat_vacuum.c |  3 ++
 src/backend/utils/adt/pgstatfuncs.c        |  9 ++++--
 src/include/catalog/pg_proc.dat            | 12 ++++----
 src/include/pgstat.h                       |  4 +++
 src/test/regress/expected/rules.out        | 12 +++++---
 src/test/regress/expected/vacuum_stats.out | 20 +++++++++++---
 src/test/regress/sql/vacuum_stats.sql      | 10 ++++++-
 10 files changed, 107 insertions(+), 19 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 088b532e5c..8e80db3e03 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5948,6 +5948,22 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of shared buffer blocks written out by the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of this relation's blocks read from disk by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of this relation's blocks found in shared buffers by the vacuum.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6071,6 +6087,22 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of shared buffer blocks written out while vacuuming the index.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of blocks of this index read from disk by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of block hits within this index during the vacuum.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 9c524909fe..fdd70653e3 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -569,6 +569,19 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 	report->total_blks_hit += bufusage.local_blks_hit + bufusage.shared_blks_hit;
 	report->total_blks_dirtied += bufusage.local_blks_dirtied + bufusage.shared_blks_dirtied;
 	report->total_blks_written += bufusage.shared_blks_written;
+
+	if (!rel->pgstat_info || !pgstat_track_counts)
+
+		/*
+		 * if something goes wrong or an user doesn't want to track a database
+		 * activity - just suppress it.
+		 */
+		return;
+
+	report->blks_fetched +=
+		rel->pgstat_info->counts.blocks_fetched - counters->blocks_fetched;
+	report->blks_hit +=
+		rel->pgstat_info->counts.blocks_hit - counters->blocks_hit;
 }
 
 void
@@ -691,6 +704,8 @@ extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
 	dst->common.total_blks_hit += src->common.total_blks_hit;
 	dst->common.total_blks_dirtied += src->common.total_blks_dirtied;
 	dst->common.total_blks_written += src->common.total_blks_written;
+	dst->common.blks_fetched += src->common.blks_fetched;
+	dst->common.blks_hit += src->common.blks_hit;
 	dst->common.tuples_deleted += src->common.tuples_deleted;
 
 	dst->index.pages_deleted += src->index.pages_deleted;
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index b4fd8f90c6..e7d4165844 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1579,7 +1579,9 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.total_blks_read AS total_blks_read,
         S.total_blks_hit AS total_blks_hit,
         S.total_blks_dirtied AS total_blks_dirtied,
-        S.total_blks_written AS total_blks_written
+        S.total_blks_written AS total_blks_written,
+        S.rel_blks_read AS rel_blks_read,
+        S.rel_blks_hit AS rel_blks_hit
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
@@ -1600,7 +1602,10 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             S.total_blks_read AS total_blks_read,
             S.total_blks_hit AS total_blks_hit,
             S.total_blks_dirtied AS total_blks_dirtied,
-            S.total_blks_written AS total_blks_written
+            S.total_blks_written AS total_blks_written,
+
+            S.rel_blks_read AS rel_blks_read,
+            S.rel_blks_hit AS rel_blks_hit
     FROM
             pg_class C JOIN
             pg_index X ON C.oid = X.indrelid JOIN
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 21678bf646..9b1eaf0f4a 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -43,6 +43,9 @@ pgstat_accumulate_common(PgStat_CommonCounts *dst, const PgStat_CommonCounts *sr
 	ACCUMULATE_FIELD(total_blks_dirtied);
 	ACCUMULATE_FIELD(total_blks_written);
 
+	ACCUMULATE_FIELD(blks_fetched);
+	ACCUMULATE_FIELD(blks_hit);
+
 	ACCUMULATE_FIELD(tuples_deleted);
 }
 
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index fc32b20850..4f0be66621 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 15
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 17
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2414,6 +2414,8 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_hit);
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
+	values[i++] = Int64GetDatum(extvacuum->common.blks_fetched - extvacuum->common.blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.blks_hit);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
@@ -2427,7 +2429,7 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 7
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 9
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2460,6 +2462,9 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
 
+	values[i++] = Int64GetDatum(extvacuum->common.blks_fetched - extvacuum->common.blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.blks_hit);
+
 	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
 
 	/* Returns the record as Datum */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index edf3cc8b62..14f070a941 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
@@ -12718,9 +12718,9 @@
   proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit}',
   prosrc => 'pg_stat_get_vacuum_indexes' },
 { oid => '8005',
   descr => 'pg_stat_get_vacuum_database returns vacuum stats values for database',
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index 6a9c9800b7..de38949d12 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -181,6 +181,10 @@ typedef struct PgStat_CommonCounts
 	int64		total_blks_dirtied;
 	int64		total_blks_written;
 
+	/* heap blocks */
+	int64		blks_fetched;
+	int64		blks_hit;
+
 	/* tuples */
 	int64		tuples_deleted;
 }			PgStat_CommonCounts;
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 7b67fe72a6..0b28e2ffc3 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2440,12 +2440,14 @@ pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     s.total_blks_read,
     s.total_blks_hit,
     s.total_blks_dirtied,
-    s.total_blks_written
+    s.total_blks_written,
+    s.rel_blks_read,
+    s.rel_blks_hit
    FROM (((pg_class c
      JOIN pg_index x ON ((c.oid = x.indrelid)))
      JOIN pg_class i ON ((i.oid = x.indexrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written)
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     c.relname,
@@ -2463,10 +2465,12 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.total_blks_read,
     s.total_blks_hit,
     s.total_blks_dirtied,
-    s.total_blks_written
+    s.total_blks_written,
+    s.rel_blks_read,
+    s.rel_blks_hit
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index 6c46239eb6..bf2fbc7626 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -114,6 +114,16 @@ SELECT total_blks_read >= 0 AS total_blks_read,
  t               | t              | t                  | t
 (1 row)
 
+-- per-relation buffer access.  The heap is read through the buffer cache
+-- (rel_blks_hit > 0); rel_blks_read depends on the run-time cache state.
+SELECT rel_blks_read >= 0 AS rel_blks_read,
+       rel_blks_hit > 0 AS rel_blks_hit
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ rel_blks_read | rel_blks_hit 
+---------------+--------------
+ t             | t
+(1 row)
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -125,11 +135,13 @@ SELECT indexrelname,
        total_blks_read >= 0 AS total_blks_read,
        total_blks_hit > 0 AS total_blks_hit,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
-       total_blks_written >= 0 AS total_blks_written
+       total_blks_written >= 0 AS total_blks_written,
+       rel_blks_read >= 0 AS rel_blks_read,
+       rel_blks_hit > 0 AS rel_blks_hit
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
-  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written 
-----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------
- vacstat_t_pkey | t             | t              | t               | t              | t                  | t
+  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit 
+----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------
+ vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t
 (1 row)
 
 -- index page-deletion path: deleting a contiguous key range empties whole
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index 91079759ea..49ed3b4063 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -88,6 +88,12 @@ SELECT total_blks_read >= 0 AS total_blks_read,
        total_blks_written >= 0 AS total_blks_written
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
 
+-- per-relation buffer access.  The heap is read through the buffer cache
+-- (rel_blks_hit > 0); rel_blks_read depends on the run-time cache state.
+SELECT rel_blks_read >= 0 AS rel_blks_read,
+       rel_blks_hit > 0 AS rel_blks_hit
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -99,7 +105,9 @@ SELECT indexrelname,
        total_blks_read >= 0 AS total_blks_read,
        total_blks_hit > 0 AS total_blks_hit,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
-       total_blks_written >= 0 AS total_blks_written
+       total_blks_written >= 0 AS total_blks_written,
+       rel_blks_read >= 0 AS rel_blks_read,
+       rel_blks_hit > 0 AS rel_blks_hit
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
 
 -- index page-deletion path: deleting a contiguous key range empties whole
-- 
2.39.5 (Apple Git-154)


From 42dffa8754bc2a554bec5a9662f5afdbb379d6f2 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:53:03 +0300
Subject: [PATCH 7/8] Extended vacuum statistics: timing metrics and failsafe
 for tables, indexes and database

Expose the timing counters and the wraparound failsafe count in
pg_stat_vacuum_tables, pg_stat_vacuum_indexes and pg_stat_vacuum_database,
with documentation and regression coverage:

  blk_read_time   time spent reading blocks (with track_io_timing)
  blk_write_time  time spent writing blocks (with track_io_timing)
  delay_time      time spent in vacuum cost-based delay
  total_time      total wall-clock time of the vacuum

  wraparound_failsafe  number of vacuums that engaged the wraparound
                       failsafe (tables expose it as a per-relation flag,
                       the database aggregate as a count)

total_time is always positive; the regression test also exercises the
positive delay_time path with a dedicated cost-delayed vacuum.  The positive
wraparound_failsafe path requires reaching the failsafe XID age and is covered
by a separate TAP test under src/test/modules/xid_wraparound.
---
 doc/src/sgml/system-views.sgml                | 112 ++++++++++++++++++
 src/backend/access/heap/vacuumlazy.c          |  28 +++++
 src/backend/catalog/system_views.sql          |  23 +++-
 src/backend/utils/activity/pgstat_vacuum.c    |   6 +
 src/backend/utils/adt/pgstatfuncs.c           |  23 +++-
 src/include/catalog/pg_proc.dat               |  18 +--
 src/include/pgstat.h                          |   9 ++
 src/test/modules/xid_wraparound/meson.build   |   1 +
 .../t/005_vacuum_stats_failsafe.pl            |  66 +++++++++++
 src/test/regress/expected/rules.out           |  26 +++-
 src/test/regress/expected/vacuum_stats.out    |  62 ++++++++--
 src/test/regress/sql/vacuum_stats.sql         |  42 ++++++-
 12 files changed, 385 insertions(+), 31 deletions(-)
 create mode 100644 src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 8e80db3e03..0443cbad40 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5964,6 +5964,46 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of this relation's blocks found in shared buffers by the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_read_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent reading blocks by the vacuum, in milliseconds (when <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_write_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent writing (flushing) blocks by the vacuum, in milliseconds; remains zero if no flushes occurred.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>delay_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Total time the vacuum spent sleeping in vacuum delay points, in milliseconds.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Total wall-clock time of the vacuum, in milliseconds, including time spent waiting for I/O and locks.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wraparound_failsafe</structfield> <type>integer</type>
+      </para>
+      <para>
+       Number of times the failsafe mechanism was triggered to prevent transaction ID wraparound during the vacuum.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6103,6 +6143,38 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of block hits within this index during the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_read_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent reading index blocks, in milliseconds (if <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_write_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent writing index blocks, in milliseconds (if <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>delay_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent in vacuum cost-based delay while processing this index, in milliseconds.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Total time spent vacuuming this index, in milliseconds.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6193,6 +6265,46 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of shared buffer blocks written out by vacuum operations in this database.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wraparound_failsafe</structfield> <type>integer</type>
+      </para>
+      <para>
+       Number of vacuum operations in this database that engaged the wraparound failsafe mechanism.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_read_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent reading blocks by vacuum operations in this database, in milliseconds (if <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_write_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent writing blocks by vacuum operations in this database, in milliseconds (if <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>delay_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent in vacuum cost-based delay by vacuum operations in this database, in milliseconds.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Total time spent by vacuum operations in this database, in milliseconds.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index fdd70653e3..997eabf1ec 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -552,6 +552,9 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 				 PgStat_CommonCounts * report)
 {
 	BufferUsage bufusage;
+	TimestampTz endtime;
+	long		secs;
+	int			usecs;
 
 	if (!pgstat_track_vacuum_statistics)
 		return;
@@ -562,6 +565,9 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 	memset(&bufusage, 0, sizeof(BufferUsage));
 	BufferUsageAccumDiff(&bufusage, &pgBufferUsage, &counters->bufusage);
 
+	endtime = GetCurrentTimestamp();
+	TimestampDifference(counters->starttime, endtime, &secs, &usecs);
+
 	/*
 	 * Fill additional statistics on a vacuum processing operation.
 	 */
@@ -570,6 +576,14 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 	report->total_blks_dirtied += bufusage.local_blks_dirtied + bufusage.shared_blks_dirtied;
 	report->total_blks_written += bufusage.shared_blks_written;
 
+	report->blk_read_time += INSTR_TIME_GET_MILLISEC(bufusage.local_blk_read_time);
+	report->blk_read_time += INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_read_time);
+	report->blk_write_time += INSTR_TIME_GET_MILLISEC(bufusage.local_blk_write_time);
+	report->blk_write_time += INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_write_time);
+	report->delay_time += VacuumDelayTime - counters->VacuumDelayTime;
+
+	report->total_time += secs * 1000. + usecs / 1000.;
+
 	if (!rel->pgstat_info || !pgstat_track_counts)
 
 		/*
@@ -667,21 +681,31 @@ accumulate_heap_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCount
 	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
 	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
 	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
+	extVacStats->common.wraparound_failsafe_count = vacrel->wraparound_failsafe_count;
 
+	extVacStats->common.blk_read_time -= vacrel->extVacReportIdx.common.blk_read_time;
+	extVacStats->common.blk_write_time -= vacrel->extVacReportIdx.common.blk_write_time;
 	extVacStats->common.total_blks_dirtied -= vacrel->extVacReportIdx.common.total_blks_dirtied;
 	extVacStats->common.total_blks_hit -= vacrel->extVacReportIdx.common.total_blks_hit;
 	extVacStats->common.total_blks_read -= vacrel->extVacReportIdx.common.total_blks_read;
 	extVacStats->common.total_blks_written -= vacrel->extVacReportIdx.common.total_blks_written;
+
+	extVacStats->common.total_time -= vacrel->extVacReportIdx.common.total_time;
+	extVacStats->common.delay_time -= vacrel->extVacReportIdx.common.delay_time;
 }
 
 static void
 accumulate_idxs_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCounts * extVacIdxStats)
 {
 	/* Fill heap-specific extended stats fields */
+	vacrel->extVacReportIdx.common.blk_read_time += extVacIdxStats->common.blk_read_time;
+	vacrel->extVacReportIdx.common.blk_write_time += extVacIdxStats->common.blk_write_time;
 	vacrel->extVacReportIdx.common.total_blks_dirtied += extVacIdxStats->common.total_blks_dirtied;
 	vacrel->extVacReportIdx.common.total_blks_hit += extVacIdxStats->common.total_blks_hit;
 	vacrel->extVacReportIdx.common.total_blks_read += extVacIdxStats->common.total_blks_read;
 	vacrel->extVacReportIdx.common.total_blks_written += extVacIdxStats->common.total_blks_written;
+	vacrel->extVacReportIdx.common.delay_time += extVacIdxStats->common.delay_time;
+	vacrel->extVacReportIdx.common.total_time += extVacIdxStats->common.total_time;
 }
 
 /*
@@ -706,6 +730,10 @@ extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
 	dst->common.total_blks_written += src->common.total_blks_written;
 	dst->common.blks_fetched += src->common.blks_fetched;
 	dst->common.blks_hit += src->common.blks_hit;
+	dst->common.blk_read_time += src->common.blk_read_time;
+	dst->common.blk_write_time += src->common.blk_write_time;
+	dst->common.delay_time += src->common.delay_time;
+	dst->common.total_time += src->common.total_time;
 	dst->common.tuples_deleted += src->common.tuples_deleted;
 
 	dst->index.pages_deleted += src->index.pages_deleted;
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index e7d4165844..11135733c2 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1581,7 +1581,12 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.total_blks_dirtied AS total_blks_dirtied,
         S.total_blks_written AS total_blks_written,
         S.rel_blks_read AS rel_blks_read,
-        S.rel_blks_hit AS rel_blks_hit
+        S.rel_blks_hit AS rel_blks_hit,
+        S.blk_read_time AS blk_read_time,
+        S.blk_write_time AS blk_write_time,
+        S.delay_time AS delay_time,
+        S.total_time AS total_time,
+        S.wraparound_failsafe AS wraparound_failsafe
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
@@ -1605,7 +1610,12 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             S.total_blks_written AS total_blks_written,
 
             S.rel_blks_read AS rel_blks_read,
-            S.rel_blks_hit AS rel_blks_hit
+            S.rel_blks_hit AS rel_blks_hit,
+
+            S.blk_read_time AS blk_read_time,
+            S.blk_write_time AS blk_write_time,
+            S.delay_time AS delay_time,
+            S.total_time AS total_time
     FROM
             pg_class C JOIN
             pg_index X ON C.oid = X.indrelid JOIN
@@ -1624,7 +1634,14 @@ CREATE VIEW pg_stat_vacuum_database AS
             S.db_blks_read AS db_blks_read,
             S.db_blks_hit AS db_blks_hit,
             S.total_blks_dirtied AS total_blks_dirtied,
-            S.total_blks_written AS total_blks_written
+            S.total_blks_written AS total_blks_written,
+
+            S.wraparound_failsafe AS wraparound_failsafe,
+
+            S.blk_read_time AS blk_read_time,
+            S.blk_write_time AS blk_write_time,
+            S.delay_time AS delay_time,
+            S.total_time AS total_time
     FROM
             pg_database D,
             LATERAL pg_stat_get_vacuum_database(D.oid) S;
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 9b1eaf0f4a..d0e2eea258 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -46,7 +46,13 @@ pgstat_accumulate_common(PgStat_CommonCounts *dst, const PgStat_CommonCounts *sr
 	ACCUMULATE_FIELD(blks_fetched);
 	ACCUMULATE_FIELD(blks_hit);
 
+	ACCUMULATE_FIELD(blk_read_time);
+	ACCUMULATE_FIELD(blk_write_time);
+	ACCUMULATE_FIELD(delay_time);
+	ACCUMULATE_FIELD(total_time);
+
 	ACCUMULATE_FIELD(tuples_deleted);
+	ACCUMULATE_FIELD(wraparound_failsafe_count);
 }
 
 /*
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 4f0be66621..75ebff6d68 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 17
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 22
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2416,6 +2416,11 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
 	values[i++] = Int64GetDatum(extvacuum->common.blks_fetched - extvacuum->common.blks_hit);
 	values[i++] = Int64GetDatum(extvacuum->common.blks_hit);
+	values[i++] = Float8GetDatum(extvacuum->common.blk_read_time);
+	values[i++] = Float8GetDatum(extvacuum->common.blk_write_time);
+	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
+	values[i++] = Float8GetDatum(extvacuum->common.total_time);
+	values[i++] = Int32GetDatum(extvacuum->common.wraparound_failsafe_count);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
@@ -2429,7 +2434,7 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 9
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 13
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2465,6 +2470,11 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.blks_fetched - extvacuum->common.blks_hit);
 	values[i++] = Int64GetDatum(extvacuum->common.blks_hit);
 
+	values[i++] = Float8GetDatum(extvacuum->common.blk_read_time);
+	values[i++] = Float8GetDatum(extvacuum->common.blk_write_time);
+	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
+	values[i++] = Float8GetDatum(extvacuum->common.total_time);
+
 	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
 
 	/* Returns the record as Datum */
@@ -2477,7 +2487,7 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 6
+#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 11
 
 	Oid			dbid = PG_GETARG_OID(0);
 	PgStat_VacuumDBCounts *extvacuum;
@@ -2505,6 +2515,13 @@ pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
 
+	values[i++] = Int32GetDatum(extvacuum->common.wraparound_failsafe_count);
+
+	values[i++] = Float8GetDatum(extvacuum->common.blk_read_time);
+	values[i++] = Float8GetDatum(extvacuum->common.blk_write_time);
+	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
+	values[i++] = Float8GetDatum(extvacuum->common.total_time);
+
 	Assert(i == PG_STAT_GET_VACUUM_DATABASE_STATS_COLS);
 
 	/* Returns the record as Datum */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 14f070a941..75d6e2b329 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8,int4}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time,wraparound_failsafe}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
@@ -12718,17 +12718,17 @@
   proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time}',
   prosrc => 'pg_stat_get_vacuum_indexes' },
 { oid => '8005',
   descr => 'pg_stat_get_vacuum_database returns vacuum stats values for database',
   proname => 'pg_stat_get_vacuum_database', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o}',
-  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written}',
+  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8,int4,float8,float8,float8,float8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written,wraparound_failsafe,blk_read_time,blk_write_time,delay_time,total_time}',
   prosrc => 'pg_stat_get_vacuum_database' },
 ]
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index de38949d12..bfc2995389 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -185,8 +185,17 @@ typedef struct PgStat_CommonCounts
 	int64		blks_fetched;
 	int64		blks_hit;
 
+	/* Time */
+	double		blk_read_time;
+	double		blk_write_time;
+	double		delay_time;
+	double		total_time;
+
 	/* tuples */
 	int64		tuples_deleted;
+
+	/* failsafe */
+	int32		wraparound_failsafe_count;
 }			PgStat_CommonCounts;
 
 /* ----------
diff --git a/src/test/modules/xid_wraparound/meson.build b/src/test/modules/xid_wraparound/meson.build
index 97ce670f9a..9224e59d1d 100644
--- a/src/test/modules/xid_wraparound/meson.build
+++ b/src/test/modules/xid_wraparound/meson.build
@@ -31,6 +31,7 @@ tests += {
       't/002_limits.pl',
       't/003_wraparounds.pl',
       't/004_notify_freeze.pl',
+      't/005_vacuum_stats_failsafe.pl',
     ],
   },
 }
diff --git a/src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl b/src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl
new file mode 100644
index 0000000000..eff77fe876
--- /dev/null
+++ b/src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl
@@ -0,0 +1,66 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+
+# Test that the wraparound failsafe counter exposed by the extended vacuum
+# statistics views advances when a VACUUM engages the wraparound failsafe.
+#
+# The failsafe only triggers once a relation's age exceeds
+# max(vacuum_failsafe_age, autovacuum_freeze_max_age * 1.05), which cannot be
+# reached by an ordinary regression test.  Here we lower
+# autovacuum_freeze_max_age to its minimum and use the xid_wraparound
+# extension to burn enough transaction IDs to age the table past that
+# threshold, then check that the wraparound_failsafe counters advance.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bxid_wraparound\b/)
+{
+	plan skip_all => "test xid_wraparound not enabled in PG_TEST_EXTRA";
+}
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf(
+	'postgresql.conf', qq[
+autovacuum = off
+track_vacuum_statistics = on
+# Lower the wraparound-failsafe threshold as far as possible so that a modest
+# number of consumed XIDs is enough to engage the failsafe.
+autovacuum_freeze_max_age = 100000
+vacuum_failsafe_age = 0
+]);
+$node->start;
+$node->safe_psql('postgres', 'CREATE EXTENSION xid_wraparound');
+
+# A table whose relfrozenxid age we will push past the failsafe threshold.
+$node->safe_psql(
+	'postgres', qq[
+CREATE TABLE fs_tab (id int) WITH (autovacuum_enabled = off);
+INSERT INTO fs_tab SELECT generate_series(1, 1000);
+]);
+
+# Advance the XID counter well past the failsafe threshold.
+$node->safe_psql('postgres', 'SELECT consume_xids(200000)');
+
+# This VACUUM must engage the wraparound failsafe.
+$node->safe_psql('postgres', 'VACUUM fs_tab');
+
+# The per-table view records that the failsafe was engaged for this relation.
+my $tab = $node->safe_psql(
+	'postgres', qq[
+SELECT wraparound_failsafe > 0
+  FROM pg_stat_vacuum_tables WHERE relname = 'fs_tab']);
+is($tab, 't', 'wraparound_failsafe advanced in pg_stat_vacuum_tables');
+
+# The per-database aggregate counts the failsafe as well.
+my $db = $node->safe_psql(
+	'postgres', qq[
+SELECT wraparound_failsafe > 0
+  FROM pg_stat_vacuum_database WHERE dbname = current_database()]);
+is($db, 't', 'wraparound_failsafe advanced in pg_stat_vacuum_database');
+
+$node->stop;
+done_testing();
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 0b28e2ffc3..952392f66d 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2427,9 +2427,14 @@ pg_stat_vacuum_database| SELECT d.oid AS dboid,
     s.db_blks_read,
     s.db_blks_hit,
     s.total_blks_dirtied,
-    s.total_blks_written
+    s.total_blks_written,
+    s.wraparound_failsafe,
+    s.blk_read_time,
+    s.blk_write_time,
+    s.delay_time,
+    s.total_time
    FROM pg_database d,
-    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written);
+    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written, wraparound_failsafe, blk_read_time, blk_write_time, delay_time, total_time);
 pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     i.oid AS indexrelid,
     n.nspname AS schemaname,
@@ -2442,12 +2447,16 @@ pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     s.total_blks_dirtied,
     s.total_blks_written,
     s.rel_blks_read,
-    s.rel_blks_hit
+    s.rel_blks_hit,
+    s.blk_read_time,
+    s.blk_write_time,
+    s.delay_time,
+    s.total_time
    FROM (((pg_class c
      JOIN pg_index x ON ((c.oid = x.indrelid)))
      JOIN pg_class i ON ((i.oid = x.indexrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit)
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     c.relname,
@@ -2467,10 +2476,15 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.total_blks_dirtied,
     s.total_blks_written,
     s.rel_blks_read,
-    s.rel_blks_hit
+    s.rel_blks_hit,
+    s.blk_read_time,
+    s.blk_write_time,
+    s.delay_time,
+    s.total_time,
+    s.wraparound_failsafe
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time, wraparound_failsafe)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index bf2fbc7626..b5bae99946 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -124,6 +124,43 @@ SELECT rel_blks_read >= 0 AS rel_blks_read,
  t             | t
 (1 row)
 
+-- timing metrics and failsafe.  The vacuum always takes some wall-clock time
+-- (total_time > 0) and does not engage the wraparound failsafe under normal
+-- conditions (wraparound_failsafe = 0).  blk_read_time/blk_write_time are only
+-- non-zero when track_io_timing is enabled, and delay_time only when a vacuum
+-- cost delay is configured, so those are merely checked for being
+-- non-negative; the positive delay_time path is exercised separately below.
+SELECT blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time,
+       wraparound_failsafe = 0 AS wraparound_failsafe
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ blk_read_time | blk_write_time | delay_time | total_time | wraparound_failsafe 
+---------------+----------------+------------+------------+---------------------
+ t             | t              | t          | t          | t
+(1 row)
+
+-- delay path: with a vacuum cost delay configured, a vacuum that accrues cost
+-- sleeps, so delay_time advances.
+CREATE TABLE vacstat_delay (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off, fillfactor = 10);
+INSERT INTO vacstat_delay SELECT g, repeat('x', 100) FROM generate_series(1, 3000) g;
+DELETE FROM vacstat_delay WHERE id % 2 = 0;
+SET vacuum_cost_delay = '1ms';
+SET vacuum_cost_limit = 1;
+VACUUM vacstat_delay;
+RESET vacuum_cost_delay;
+RESET vacuum_cost_limit;
+SELECT delay_time > 0 AS delay_time,
+       total_time > 0 AS total_time
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_delay';
+ delay_time | total_time 
+------------+------------
+ t          | t
+(1 row)
+
+DROP TABLE vacstat_delay;
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -137,11 +174,15 @@ SELECT indexrelname,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
        total_blks_written >= 0 AS total_blks_written,
        rel_blks_read >= 0 AS rel_blks_read,
-       rel_blks_hit > 0 AS rel_blks_hit
+       rel_blks_hit > 0 AS rel_blks_hit,
+       blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
-  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit 
-----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------
- vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t
+  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit | blk_read_time | blk_write_time | delay_time | total_time 
+----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------+---------------+----------------+------------+------------
+ vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t            | t             | t              | t          | t
 (1 row)
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -170,10 +211,15 @@ SELECT errors = 0 AS errors,
        db_blks_read >= 0 AS db_blks_read,
        db_blks_hit > 0 AS db_blks_hit,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
-       total_blks_written >= 0 AS total_blks_written
+       total_blks_written >= 0 AS total_blks_written,
+       wraparound_failsafe = 0 AS wraparound_failsafe,
+       blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time
   FROM pg_stat_vacuum_database WHERE dbname = current_database();
- errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written 
---------+--------------+-------------+--------------------+--------------------
- t      | t            | t           | t                  | t
+ errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written | wraparound_failsafe | blk_read_time | blk_write_time | delay_time | total_time 
+--------+--------------+-------------+--------------------+--------------------+---------------------+---------------+----------------+------------+------------
+ t      | t            | t           | t                  | t                  | t                   | t             | t              | t          | t
 (1 row)
 
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index 49ed3b4063..ee517d6f44 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -94,6 +94,35 @@ SELECT rel_blks_read >= 0 AS rel_blks_read,
        rel_blks_hit > 0 AS rel_blks_hit
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
 
+-- timing metrics and failsafe.  The vacuum always takes some wall-clock time
+-- (total_time > 0) and does not engage the wraparound failsafe under normal
+-- conditions (wraparound_failsafe = 0).  blk_read_time/blk_write_time are only
+-- non-zero when track_io_timing is enabled, and delay_time only when a vacuum
+-- cost delay is configured, so those are merely checked for being
+-- non-negative; the positive delay_time path is exercised separately below.
+SELECT blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time,
+       wraparound_failsafe = 0 AS wraparound_failsafe
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
+-- delay path: with a vacuum cost delay configured, a vacuum that accrues cost
+-- sleeps, so delay_time advances.
+CREATE TABLE vacstat_delay (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off, fillfactor = 10);
+INSERT INTO vacstat_delay SELECT g, repeat('x', 100) FROM generate_series(1, 3000) g;
+DELETE FROM vacstat_delay WHERE id % 2 = 0;
+SET vacuum_cost_delay = '1ms';
+SET vacuum_cost_limit = 1;
+VACUUM vacstat_delay;
+RESET vacuum_cost_delay;
+RESET vacuum_cost_limit;
+SELECT delay_time > 0 AS delay_time,
+       total_time > 0 AS total_time
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_delay';
+DROP TABLE vacstat_delay;
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -107,7 +136,11 @@ SELECT indexrelname,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
        total_blks_written >= 0 AS total_blks_written,
        rel_blks_read >= 0 AS rel_blks_read,
-       rel_blks_hit > 0 AS rel_blks_hit
+       rel_blks_hit > 0 AS rel_blks_hit,
+       blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -132,5 +165,10 @@ SELECT errors = 0 AS errors,
        db_blks_read >= 0 AS db_blks_read,
        db_blks_hit > 0 AS db_blks_hit,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
-       total_blks_written >= 0 AS total_blks_written
+       total_blks_written >= 0 AS total_blks_written,
+       wraparound_failsafe = 0 AS wraparound_failsafe,
+       blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time
   FROM pg_stat_vacuum_database WHERE dbname = current_database();
-- 
2.39.5 (Apple Git-154)


From 868f4512b0f73f8bb1869e7be33ef0d6035332b3 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:53:49 +0300
Subject: [PATCH 8/8] Extended vacuum statistics: WAL metrics for tables,
 indexes and database

Expose the WAL generation counters in pg_stat_vacuum_tables,
pg_stat_vacuum_indexes and pg_stat_vacuum_database, with documentation and
regression coverage:

  wal_records  number of WAL records generated by the vacuum
  wal_fpi      number of WAL full page images generated by the vacuum
  wal_bytes    total amount of WAL generated by the vacuum, in bytes

A vacuum that removes tuples always emits WAL, so wal_records and wal_bytes
are positive.  wal_fpi depends on checkpoint timing; the regression test
exercises its positive path with a dedicated vacuum run right after a
CHECKPOINT.

This completes the extended vacuum statistics views: every metric category is
now reported for tables, indexes and the database aggregate.
---
 doc/src/sgml/system-views.sgml             | 72 ++++++++++++++++++++++
 src/backend/access/heap/vacuumlazy.c       | 19 +++++-
 src/backend/catalog/system_views.sql       | 17 ++++-
 src/backend/utils/activity/pgstat_vacuum.c |  4 ++
 src/backend/utils/adt/pgstatfuncs.c        | 32 +++++++++-
 src/include/catalog/pg_proc.dat            | 18 +++---
 src/include/pgstat.h                       |  5 ++
 src/test/regress/expected/rules.out        | 21 +++++--
 src/test/regress/expected/vacuum_stats.out | 54 +++++++++++++---
 src/test/regress/sql/vacuum_stats.sql      | 34 +++++++++-
 10 files changed, 244 insertions(+), 32 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 0443cbad40..50fcc3aa7d 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -6004,6 +6004,30 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of times the failsafe mechanism was triggered to prevent transaction ID wraparound during the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_records</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Total number of WAL records generated by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_fpi</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Total number of WAL full page images generated by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_bytes</structfield> <type>numeric</type>
+      </para>
+      <para>
+       Total amount of WAL generated by the vacuum, in bytes.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6175,6 +6199,30 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Total time spent vacuuming this index, in milliseconds.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_records</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of WAL records generated while vacuuming this index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_fpi</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of WAL full page images generated while vacuuming this index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_bytes</structfield> <type>numeric</type>
+      </para>
+      <para>
+       Total amount of WAL generated while vacuuming this index, in bytes.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6305,6 +6353,30 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Total time spent by vacuum operations in this database, in milliseconds.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_records</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of WAL records generated by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_fpi</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of WAL full page images generated by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_bytes</structfield> <type>numeric</type>
+      </para>
+      <para>
+       Total amount of WAL generated by vacuum operations in this database, in bytes.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 997eabf1ec..8c2e595a13 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -551,6 +551,7 @@ static void
 extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 				 PgStat_CommonCounts * report)
 {
+	WalUsage	walusage;
 	BufferUsage bufusage;
 	TimestampTz endtime;
 	long		secs;
@@ -561,7 +562,10 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 
 	memset(report, 0, sizeof(PgStat_CommonCounts));
 
-	/* Calculate diffs of global stat parameters on buffer usage. */
+	/* Calculate diffs of global stat parameters on WAL and buffer usage. */
+	memset(&walusage, 0, sizeof(WalUsage));
+	WalUsageAccumDiff(&walusage, &pgWalUsage, &counters->walusage);
+
 	memset(&bufusage, 0, sizeof(BufferUsage));
 	BufferUsageAccumDiff(&bufusage, &pgBufferUsage, &counters->bufusage);
 
@@ -576,6 +580,10 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 	report->total_blks_dirtied += bufusage.local_blks_dirtied + bufusage.shared_blks_dirtied;
 	report->total_blks_written += bufusage.shared_blks_written;
 
+	report->wal_records += walusage.wal_records;
+	report->wal_fpi += walusage.wal_fpi;
+	report->wal_bytes += walusage.wal_bytes;
+
 	report->blk_read_time += INSTR_TIME_GET_MILLISEC(bufusage.local_blk_read_time);
 	report->blk_read_time += INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_read_time);
 	report->blk_write_time += INSTR_TIME_GET_MILLISEC(bufusage.local_blk_write_time);
@@ -689,6 +697,9 @@ accumulate_heap_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCount
 	extVacStats->common.total_blks_hit -= vacrel->extVacReportIdx.common.total_blks_hit;
 	extVacStats->common.total_blks_read -= vacrel->extVacReportIdx.common.total_blks_read;
 	extVacStats->common.total_blks_written -= vacrel->extVacReportIdx.common.total_blks_written;
+	extVacStats->common.wal_bytes -= vacrel->extVacReportIdx.common.wal_bytes;
+	extVacStats->common.wal_fpi -= vacrel->extVacReportIdx.common.wal_fpi;
+	extVacStats->common.wal_records -= vacrel->extVacReportIdx.common.wal_records;
 
 	extVacStats->common.total_time -= vacrel->extVacReportIdx.common.total_time;
 	extVacStats->common.delay_time -= vacrel->extVacReportIdx.common.delay_time;
@@ -704,6 +715,9 @@ accumulate_idxs_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCount
 	vacrel->extVacReportIdx.common.total_blks_hit += extVacIdxStats->common.total_blks_hit;
 	vacrel->extVacReportIdx.common.total_blks_read += extVacIdxStats->common.total_blks_read;
 	vacrel->extVacReportIdx.common.total_blks_written += extVacIdxStats->common.total_blks_written;
+	vacrel->extVacReportIdx.common.wal_bytes += extVacIdxStats->common.wal_bytes;
+	vacrel->extVacReportIdx.common.wal_fpi += extVacIdxStats->common.wal_fpi;
+	vacrel->extVacReportIdx.common.wal_records += extVacIdxStats->common.wal_records;
 	vacrel->extVacReportIdx.common.delay_time += extVacIdxStats->common.delay_time;
 	vacrel->extVacReportIdx.common.total_time += extVacIdxStats->common.total_time;
 }
@@ -730,6 +744,9 @@ extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
 	dst->common.total_blks_written += src->common.total_blks_written;
 	dst->common.blks_fetched += src->common.blks_fetched;
 	dst->common.blks_hit += src->common.blks_hit;
+	dst->common.wal_records += src->common.wal_records;
+	dst->common.wal_fpi += src->common.wal_fpi;
+	dst->common.wal_bytes += src->common.wal_bytes;
 	dst->common.blk_read_time += src->common.blk_read_time;
 	dst->common.blk_write_time += src->common.blk_write_time;
 	dst->common.delay_time += src->common.delay_time;
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 11135733c2..31c97120b6 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1586,7 +1586,10 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.blk_write_time AS blk_write_time,
         S.delay_time AS delay_time,
         S.total_time AS total_time,
-        S.wraparound_failsafe AS wraparound_failsafe
+        S.wraparound_failsafe AS wraparound_failsafe,
+        S.wal_records AS wal_records,
+        S.wal_fpi AS wal_fpi,
+        S.wal_bytes AS wal_bytes
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
@@ -1615,7 +1618,11 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             S.blk_read_time AS blk_read_time,
             S.blk_write_time AS blk_write_time,
             S.delay_time AS delay_time,
-            S.total_time AS total_time
+            S.total_time AS total_time,
+
+            S.wal_records AS wal_records,
+            S.wal_fpi AS wal_fpi,
+            S.wal_bytes AS wal_bytes
     FROM
             pg_class C JOIN
             pg_index X ON C.oid = X.indrelid JOIN
@@ -1641,7 +1648,11 @@ CREATE VIEW pg_stat_vacuum_database AS
             S.blk_read_time AS blk_read_time,
             S.blk_write_time AS blk_write_time,
             S.delay_time AS delay_time,
-            S.total_time AS total_time
+            S.total_time AS total_time,
+
+            S.wal_records AS wal_records,
+            S.wal_fpi AS wal_fpi,
+            S.wal_bytes AS wal_bytes
     FROM
             pg_database D,
             LATERAL pg_stat_get_vacuum_database(D.oid) S;
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index d0e2eea258..c0a6bf9e97 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -46,6 +46,10 @@ pgstat_accumulate_common(PgStat_CommonCounts *dst, const PgStat_CommonCounts *sr
 	ACCUMULATE_FIELD(blks_fetched);
 	ACCUMULATE_FIELD(blks_hit);
 
+	ACCUMULATE_FIELD(wal_records);
+	ACCUMULATE_FIELD(wal_fpi);
+	ACCUMULATE_FIELD(wal_bytes);
+
 	ACCUMULATE_FIELD(blk_read_time);
 	ACCUMULATE_FIELD(blk_write_time);
 	ACCUMULATE_FIELD(delay_time);
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 75ebff6d68..a03b7ef17b 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,13 +2374,14 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 22
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 25
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
 	TupleDesc	tupdesc;
 	Datum		values[PG_STAT_GET_VACUUM_TABLES_STATS_COLS] = {0};
 	bool		nulls[PG_STAT_GET_VACUUM_TABLES_STATS_COLS] = {0};
+	char		buf[256];
 	int			i = 0;
 
 	/* Build a tuple descriptor for our result type */
@@ -2421,6 +2422,13 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
 	values[i++] = Float8GetDatum(extvacuum->common.total_time);
 	values[i++] = Int32GetDatum(extvacuum->common.wraparound_failsafe_count);
+	values[i++] = Int64GetDatum(extvacuum->common.wal_records);
+	values[i++] = Int64GetDatum(extvacuum->common.wal_fpi);
+	snprintf(buf, sizeof buf, UINT64_FORMAT, extvacuum->common.wal_bytes);
+	values[i++] = DirectFunctionCall3(numeric_in,
+									  CStringGetDatum(buf),
+									  ObjectIdGetDatum(0),
+									  Int32GetDatum(-1));
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
@@ -2434,13 +2442,14 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 13
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 16
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
 	TupleDesc	tupdesc;
 	Datum		values[PG_STAT_GET_VACUUM_INDEX_STATS_COLS] = {0};
 	bool		nulls[PG_STAT_GET_VACUUM_INDEX_STATS_COLS] = {0};
+	char		buf[256];
 	int			i = 0;
 
 	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
@@ -2475,6 +2484,14 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
 	values[i++] = Float8GetDatum(extvacuum->common.total_time);
 
+	values[i++] = Int64GetDatum(extvacuum->common.wal_records);
+	values[i++] = Int64GetDatum(extvacuum->common.wal_fpi);
+	snprintf(buf, sizeof buf, UINT64_FORMAT, extvacuum->common.wal_bytes);
+	values[i++] = DirectFunctionCall3(numeric_in,
+									  CStringGetDatum(buf),
+									  ObjectIdGetDatum(0),
+									  Int32GetDatum(-1));
+
 	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
 
 	/* Returns the record as Datum */
@@ -2487,13 +2504,14 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 11
+#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 14
 
 	Oid			dbid = PG_GETARG_OID(0);
 	PgStat_VacuumDBCounts *extvacuum;
 	TupleDesc	tupdesc;
 	Datum		values[PG_STAT_GET_VACUUM_DATABASE_STATS_COLS] = {0};
 	bool		nulls[PG_STAT_GET_VACUUM_DATABASE_STATS_COLS] = {0};
+	char		buf[256];
 	int			i = 0;
 
 	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
@@ -2522,6 +2540,14 @@ pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
 	values[i++] = Float8GetDatum(extvacuum->common.total_time);
 
+	values[i++] = Int64GetDatum(extvacuum->common.wal_records);
+	values[i++] = Int64GetDatum(extvacuum->common.wal_fpi);
+	snprintf(buf, sizeof buf, UINT64_FORMAT, extvacuum->common.wal_bytes);
+	values[i++] = DirectFunctionCall3(numeric_in,
+									  CStringGetDatum(buf),
+									  ObjectIdGetDatum(0),
+									  Int32GetDatum(-1));
+
 	Assert(i == PG_STAT_GET_VACUUM_DATABASE_STATS_COLS);
 
 	/* Returns the record as Datum */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 75d6e2b329..4e64f42042 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8,int4}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time,wraparound_failsafe}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8,int4,int8,int8,numeric}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time,wraparound_failsafe,wal_records,wal_fpi,wal_bytes}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
@@ -12718,17 +12718,17 @@
   proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8,int8,int8,numeric}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time,wal_records,wal_fpi,wal_bytes}',
   prosrc => 'pg_stat_get_vacuum_indexes' },
 { oid => '8005',
   descr => 'pg_stat_get_vacuum_database returns vacuum stats values for database',
   proname => 'pg_stat_get_vacuum_database', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8,int4,float8,float8,float8,float8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written,wraparound_failsafe,blk_read_time,blk_write_time,delay_time,total_time}',
+  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8,int4,float8,float8,float8,float8,int8,int8,numeric}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written,wraparound_failsafe,blk_read_time,blk_write_time,delay_time,total_time,wal_records,wal_fpi,wal_bytes}',
   prosrc => 'pg_stat_get_vacuum_database' },
 ]
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index bfc2995389..88f2b027af 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -185,6 +185,11 @@ typedef struct PgStat_CommonCounts
 	int64		blks_fetched;
 	int64		blks_hit;
 
+	/* WAL */
+	int64		wal_records;
+	int64		wal_fpi;
+	uint64		wal_bytes;
+
 	/* Time */
 	double		blk_read_time;
 	double		blk_write_time;
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 952392f66d..d0d2135c06 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2432,9 +2432,12 @@ pg_stat_vacuum_database| SELECT d.oid AS dboid,
     s.blk_read_time,
     s.blk_write_time,
     s.delay_time,
-    s.total_time
+    s.total_time,
+    s.wal_records,
+    s.wal_fpi,
+    s.wal_bytes
    FROM pg_database d,
-    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written, wraparound_failsafe, blk_read_time, blk_write_time, delay_time, total_time);
+    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written, wraparound_failsafe, blk_read_time, blk_write_time, delay_time, total_time, wal_records, wal_fpi, wal_bytes);
 pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     i.oid AS indexrelid,
     n.nspname AS schemaname,
@@ -2451,12 +2454,15 @@ pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     s.blk_read_time,
     s.blk_write_time,
     s.delay_time,
-    s.total_time
+    s.total_time,
+    s.wal_records,
+    s.wal_fpi,
+    s.wal_bytes
    FROM (((pg_class c
      JOIN pg_index x ON ((c.oid = x.indrelid)))
      JOIN pg_class i ON ((i.oid = x.indexrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time)
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time, wal_records, wal_fpi, wal_bytes)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     c.relname,
@@ -2481,10 +2487,13 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.blk_write_time,
     s.delay_time,
     s.total_time,
-    s.wraparound_failsafe
+    s.wraparound_failsafe,
+    s.wal_records,
+    s.wal_fpi,
+    s.wal_bytes
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time, wraparound_failsafe)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time, wraparound_failsafe, wal_records, wal_fpi, wal_bytes)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index b5bae99946..30fadd1e6a 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -161,6 +161,38 @@ SELECT delay_time > 0 AS delay_time,
 (1 row)
 
 DROP TABLE vacstat_delay;
+-- WAL metrics.  A vacuum that removes tuples always emits WAL
+-- (wal_records > 0, wal_bytes > 0).  wal_fpi depends on whether a checkpoint
+-- happened recently, so it is only checked for being non-negative here; the
+-- positive wal_fpi path is exercised separately below.
+SELECT wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ wal_records | wal_fpi | wal_bytes 
+-------------+---------+-----------
+ t           | t       | t
+(1 row)
+
+-- WAL full-page-image path: a CHECKPOINT immediately before the vacuum forces
+-- the first modification of each page to emit a full page image, so wal_fpi
+-- advances.
+CREATE TABLE vacstat_fpi (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_fpi SELECT g, repeat('x', 20) FROM generate_series(1, 1000) g;
+DELETE FROM vacstat_fpi WHERE id % 2 = 0;
+CHECKPOINT;
+VACUUM vacstat_fpi;
+SELECT wal_records > 0 AS wal_records,
+       wal_fpi > 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_fpi';
+ wal_records | wal_fpi | wal_bytes 
+-------------+---------+-----------
+ t           | t       | t
+(1 row)
+
+DROP TABLE vacstat_fpi;
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -178,11 +210,14 @@ SELECT indexrelname,
        blk_read_time >= 0 AS blk_read_time,
        blk_write_time >= 0 AS blk_write_time,
        delay_time >= 0 AS delay_time,
-       total_time > 0 AS total_time
+       total_time > 0 AS total_time,
+       wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
-  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit | blk_read_time | blk_write_time | delay_time | total_time 
-----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------+---------------+----------------+------------+------------
- vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t            | t             | t              | t          | t
+  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit | blk_read_time | blk_write_time | delay_time | total_time | wal_records | wal_fpi | wal_bytes 
+----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------+---------------+----------------+------------+------------+-------------+---------+-----------
+ vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t            | t             | t              | t          | t          | t           | t       | t
 (1 row)
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -216,10 +251,13 @@ SELECT errors = 0 AS errors,
        blk_read_time >= 0 AS blk_read_time,
        blk_write_time >= 0 AS blk_write_time,
        delay_time >= 0 AS delay_time,
-       total_time > 0 AS total_time
+       total_time > 0 AS total_time,
+       wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
   FROM pg_stat_vacuum_database WHERE dbname = current_database();
- errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written | wraparound_failsafe | blk_read_time | blk_write_time | delay_time | total_time 
---------+--------------+-------------+--------------------+--------------------+---------------------+---------------+----------------+------------+------------
- t      | t            | t           | t                  | t                  | t                   | t             | t              | t          | t
+ errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written | wraparound_failsafe | blk_read_time | blk_write_time | delay_time | total_time | wal_records | wal_fpi | wal_bytes 
+--------+--------------+-------------+--------------------+--------------------+---------------------+---------------+----------------+------------+------------+-------------+---------+-----------
+ t      | t            | t           | t                  | t                  | t                   | t             | t              | t          | t          | t           | t       | t
 (1 row)
 
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index ee517d6f44..2a366ebdf7 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -123,6 +123,30 @@ SELECT delay_time > 0 AS delay_time,
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_delay';
 DROP TABLE vacstat_delay;
 
+-- WAL metrics.  A vacuum that removes tuples always emits WAL
+-- (wal_records > 0, wal_bytes > 0).  wal_fpi depends on whether a checkpoint
+-- happened recently, so it is only checked for being non-negative here; the
+-- positive wal_fpi path is exercised separately below.
+SELECT wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
+-- WAL full-page-image path: a CHECKPOINT immediately before the vacuum forces
+-- the first modification of each page to emit a full page image, so wal_fpi
+-- advances.
+CREATE TABLE vacstat_fpi (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_fpi SELECT g, repeat('x', 20) FROM generate_series(1, 1000) g;
+DELETE FROM vacstat_fpi WHERE id % 2 = 0;
+CHECKPOINT;
+VACUUM vacstat_fpi;
+SELECT wal_records > 0 AS wal_records,
+       wal_fpi > 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_fpi';
+DROP TABLE vacstat_fpi;
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -140,7 +164,10 @@ SELECT indexrelname,
        blk_read_time >= 0 AS blk_read_time,
        blk_write_time >= 0 AS blk_write_time,
        delay_time >= 0 AS delay_time,
-       total_time > 0 AS total_time
+       total_time > 0 AS total_time,
+       wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -170,5 +197,8 @@ SELECT errors = 0 AS errors,
        blk_read_time >= 0 AS blk_read_time,
        blk_write_time >= 0 AS blk_write_time,
        delay_time >= 0 AS delay_time,
-       total_time > 0 AS total_time
+       total_time > 0 AS total_time,
+       wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
   FROM pg_stat_vacuum_database WHERE dbname = current_database();
-- 
2.39.5 (Apple Git-154)



Attachments:

  [text/plain] 0001-Track-table-VM-stability.patch (21.9K, 3-0001-Track-table-VM-stability.patch)
  download | inline diff:
From b241935e671fc3ace8f800096f48d4cfd876b6bb Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Mon, 30 Mar 2026 09:07:24 +0300
Subject: [PATCH 1/8] Track table VM stability.

Add rev_all_visible_pages and rev_all_frozen_pages counters to
pg_stat_all_tables tracking the number of times the all-visible and
all-frozen bits are cleared in the visibility map. These bits are cleared by
backend processes during regular DML operations. Hence, the counters are placed
in table statistic entry.

A high rev_all_visible_pages rate relative to DML volume indicates
that modifications are scattered across previously-clean pages rather
than concentrated on already-dirty ones, causing index-only scans to
fall back to heap fetches.  A high rev_all_frozen_pages rate indicates
that vacuum's freezing work is being frequently undone by concurrent
DML.

Authors: Alena Rybakina <[email protected]>,
         Andrei Lepikhov <[email protected]>,
         Andrei Zubkov <[email protected]>
Reviewed-by: Dilip Kumar <[email protected]>,
         Masahiko Sawada <[email protected]>,
         Ilia Evdokimov <[email protected]>,
         Jian He <[email protected]>,
         Kirill Reshke <[email protected]>,
         Alexander Korotkov <[email protected]>,
         Jim Nasby <[email protected]>,
         Sami Imseih <[email protected]>,
         Karina Litskevich <[email protected]>,
         Andrey Borodin <[email protected]>
---
 doc/src/sgml/monitoring.sgml                  |  32 +++
 src/backend/access/heap/visibilitymap.c       |  10 +
 src/backend/catalog/system_views.sql          |   4 +-
 src/backend/utils/activity/pgstat_relation.c  |   2 +
 src/backend/utils/adt/pgstatfuncs.c           |   6 +
 src/include/catalog/pg_proc.dat               |  10 +
 src/include/pgstat.h                          |  17 +-
 .../expected/vacuum-extending-freeze.out      | 185 ++++++++++++++++++
 src/test/isolation/isolation_schedule         |   2 +
 .../specs/vacuum-extending-freeze.spec        | 117 +++++++++++
 src/test/regress/expected/rules.out           |  12 +-
 11 files changed, 392 insertions(+), 5 deletions(-)
 create mode 100644 src/test/isolation/expected/vacuum-extending-freeze.out
 create mode 100644 src/test/isolation/specs/vacuum-extending-freeze.spec

diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index 08d5b82455..3467abf6d8 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -4377,6 +4377,38 @@ description | Waiting for a newly initialized WAL file to reach durable storage
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>visible_page_marks_cleared</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of times the all-visible mark in the
+       <link linkend="storage-vm">visibility map</link> was cleared for
+       pages of this table.  The all-visible mark of a heap page is
+       cleared whenever a backend process modifies a page that was
+       previously marked all-visible by vacuum activity (whether manual
+       <command>VACUUM</command> or autovacuum).  The page must then be
+       processed again by vacuum on a subsequent run.  A high rate of
+       change in this counter means that vacuum has to repeatedly
+       re-process pages of this table.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>frozen_page_marks_cleared</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of times the all-frozen mark in the
+       <link linkend="storage-vm">visibility map</link> was cleared for
+       pages of this table.  The all-frozen mark of a heap page is cleared
+       whenever a backend process modifies a page that was previously
+       marked all-frozen by vacuum activity (manual <command>VACUUM</command>
+       or autovacuum).  The page must then be processed again by vacuum on
+       the next freeze run for this table.
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>last_vacuum</structfield> <type>timestamp with time zone</type>
diff --git a/src/backend/access/heap/visibilitymap.c b/src/backend/access/heap/visibilitymap.c
index 4fd470702a..f055ec3819 100644
--- a/src/backend/access/heap/visibilitymap.c
+++ b/src/backend/access/heap/visibilitymap.c
@@ -102,6 +102,7 @@
 #include "access/xloginsert.h"
 #include "access/xlogutils.h"
 #include "miscadmin.h"
+#include "pgstat.h"
 #include "port/pg_bitutils.h"
 #include "storage/bufmgr.h"
 #include "storage/smgr.h"
@@ -173,6 +174,15 @@ visibilitymap_clear(Relation rel, BlockNumber heapBlk, Buffer vmbuf, uint8 flags
 
 	if (map[mapByte] & mask)
 	{
+		/*
+		 * Track how often all-visible or all-frozen bits are cleared in the
+		 * visibility map.
+		 */
+		if (map[mapByte] & ((flags & VISIBILITYMAP_ALL_VISIBLE) << mapOffset))
+			pgstat_count_visible_page_marks_cleared(rel);
+		if (map[mapByte] & ((flags & VISIBILITYMAP_ALL_FROZEN) << mapOffset))
+			pgstat_count_frozen_page_marks_cleared(rel);
+
 		map[mapByte] &= ~mask;
 
 		MarkBufferDirty(vmbuf);
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 8f129baec9..b57128bb12 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -747,7 +747,9 @@ CREATE VIEW pg_stat_all_tables AS
             pg_stat_get_total_autovacuum_time(C.oid) AS total_autovacuum_time,
             pg_stat_get_total_analyze_time(C.oid) AS total_analyze_time,
             pg_stat_get_total_autoanalyze_time(C.oid) AS total_autoanalyze_time,
-            pg_stat_get_stat_reset_time(C.oid) AS stats_reset
+            pg_stat_get_stat_reset_time(C.oid) AS stats_reset,
+            pg_stat_get_visible_page_marks_cleared(C.oid) AS visible_page_marks_cleared,
+            pg_stat_get_frozen_page_marks_cleared(C.oid) AS frozen_page_marks_cleared
     FROM pg_class C LEFT JOIN
          pg_index I ON C.oid = I.indrelid
          LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c
index b2ca28f83b..92e1f60a08 100644
--- a/src/backend/utils/activity/pgstat_relation.c
+++ b/src/backend/utils/activity/pgstat_relation.c
@@ -881,6 +881,8 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 
 	tabentry->blocks_fetched += lstats->counts.blocks_fetched;
 	tabentry->blocks_hit += lstats->counts.blocks_hit;
+	tabentry->visible_page_marks_cleared += lstats->counts.visible_page_marks_cleared;
+	tabentry->frozen_page_marks_cleared += lstats->counts.frozen_page_marks_cleared;
 
 	/* Clamp live_tuples in case of negative delta_live_tuples */
 	tabentry->live_tuples = Max(tabentry->live_tuples, 0);
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 6f9c9c72de..6409987d66 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -108,6 +108,12 @@ PG_STAT_GET_RELENTRY_INT64(tuples_updated)
 /* pg_stat_get_vacuum_count */
 PG_STAT_GET_RELENTRY_INT64(vacuum_count)
 
+/* pg_stat_get_visible_page_marks_cleared */
+PG_STAT_GET_RELENTRY_INT64(visible_page_marks_cleared)
+
+/* pg_stat_get_frozen_page_marks_cleared */
+PG_STAT_GET_RELENTRY_INT64(frozen_page_marks_cleared)
+
 #define PG_STAT_GET_RELENTRY_FLOAT8(stat)						\
 Datum															\
 CppConcat(pg_stat_get_,stat)(PG_FUNCTION_ARGS)					\
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index be157a5fbe..291b039859 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12693,4 +12693,14 @@
   proname => 'hashoid8extended', prorettype => 'int8',
   proargtypes => 'oid8 int8', prosrc => 'hashoid8extended' },
 
+{ oid => '8002',
+  descr => 'statistics: number of times the all-visible marks in the visibility map were cleared for pages of this table',
+  proname => 'pg_stat_get_visible_page_marks_cleared', provolatile => 's',
+  proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
+  prosrc => 'pg_stat_get_visible_page_marks_cleared' },
+{ oid => '8003',
+  descr => 'statistics: number of times the all-frozen marks in the visibility map were cleared for pages of this table',
+  proname => 'pg_stat_get_frozen_page_marks_cleared', provolatile => 's',
+  proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
+  prosrc => 'pg_stat_get_frozen_page_marks_cleared' },
 ]
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index dfa2e83763..7db36cf8ad 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -160,6 +160,8 @@ typedef struct PgStat_TableCounts
 
 	PgStat_Counter blocks_fetched;
 	PgStat_Counter blocks_hit;
+	PgStat_Counter visible_page_marks_cleared;
+	PgStat_Counter frozen_page_marks_cleared;
 } PgStat_TableCounts;
 
 /* ----------
@@ -218,7 +220,7 @@ typedef struct PgStat_TableXactStatus
  * ------------------------------------------------------------
  */
 
-#define PGSTAT_FILE_FORMAT_ID	0x01A5BCBC
+#define PGSTAT_FILE_FORMAT_ID	0x01A5BCBD
 
 typedef struct PgStat_ArchiverStats
 {
@@ -469,6 +471,8 @@ typedef struct PgStat_StatTabEntry
 
 	PgStat_Counter blocks_fetched;
 	PgStat_Counter blocks_hit;
+	PgStat_Counter visible_page_marks_cleared;
+	PgStat_Counter frozen_page_marks_cleared;
 
 	TimestampTz last_vacuum_time;	/* user initiated vacuum */
 	PgStat_Counter vacuum_count;
@@ -749,6 +753,17 @@ extern void pgstat_report_analyze(Relation rel,
 		if (pgstat_should_count_relation(rel))						\
 			(rel)->pgstat_info->counts.blocks_hit++;				\
 	} while (0)
+/* count revocations of all-visible and all-frozen marks in visibility map */
+#define pgstat_count_visible_page_marks_cleared(rel)					\
+	do {															\
+		if (pgstat_should_count_relation(rel))						\
+			(rel)->pgstat_info->counts.visible_page_marks_cleared++;	\
+	} while (0)
+#define pgstat_count_frozen_page_marks_cleared(rel)					\
+	do {															\
+		if (pgstat_should_count_relation(rel))						\
+			(rel)->pgstat_info->counts.frozen_page_marks_cleared++;	\
+	} while (0)
 
 extern void pgstat_count_heap_insert(Relation rel, PgStat_Counter n);
 extern void pgstat_count_heap_update(Relation rel, bool hot, bool newpage);
diff --git a/src/test/isolation/expected/vacuum-extending-freeze.out b/src/test/isolation/expected/vacuum-extending-freeze.out
new file mode 100644
index 0000000000..994a8df56d
--- /dev/null
+++ b/src/test/isolation/expected/vacuum-extending-freeze.out
@@ -0,0 +1,185 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s2_vacuum_freeze s1_get_set_vm_flags_stats s1_update_table s1_get_cleared_vm_flags_stats s2_vacuum_freeze s1_get_set_vm_flags_stats s2_vacuum_freeze s1_select_from_index s2_delete_from_table s1_get_cleared_vm_flags_stats s2_vacuum_freeze s1_get_set_vm_flags_stats s1_commit s1_get_cleared_vm_flags_stats
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+step s2_vacuum_freeze: 
+    VACUUM FREEZE vestat;
+
+step s1_get_set_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT c.relallfrozen > frozen_flag_count as relallfrozen, c.relallvisible > all_visibile_flag_count as relallvisible
+        FROM pg_class c, stats_state
+        WHERE c.relname = 'vestat';
+
+    UPDATE stats_state
+        SET frozen_flag_count = c.relallfrozen,
+            all_visibile_flag_count = c.relallvisible
+        FROM pg_class c
+        WHERE c.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+relallfrozen|relallvisible
+------------+-------------
+t           |t            
+(1 row)
+
+step s1_update_table: 
+    UPDATE vestat SET x = x + 1001 where x >= 2500;
+    SELECT pg_stat_force_next_flush();
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+step s1_get_cleared_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT v.visible_page_marks_cleared > cleared_all_visibile_flag_count as visible_page_marks_cleared,
+           v.frozen_page_marks_cleared > cleared_frozen_flag_count as frozen_page_marks_cleared
+        FROM pg_stat_all_tables v, stats_state
+        WHERE v.relname = 'vestat';
+
+    UPDATE stats_state
+        SET cleared_all_visibile_flag_count = v.visible_page_marks_cleared,
+            cleared_frozen_flag_count = v.frozen_page_marks_cleared
+        FROM pg_stat_all_tables v
+        WHERE v.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+visible_page_marks_cleared|frozen_page_marks_cleared
+--------------------------+-------------------------
+t                         |t                        
+(1 row)
+
+step s2_vacuum_freeze: 
+    VACUUM FREEZE vestat;
+
+step s1_get_set_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT c.relallfrozen > frozen_flag_count as relallfrozen, c.relallvisible > all_visibile_flag_count as relallvisible
+        FROM pg_class c, stats_state
+        WHERE c.relname = 'vestat';
+
+    UPDATE stats_state
+        SET frozen_flag_count = c.relallfrozen,
+            all_visibile_flag_count = c.relallvisible
+        FROM pg_class c
+        WHERE c.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+relallfrozen|relallvisible
+------------+-------------
+t           |t            
+(1 row)
+
+step s2_vacuum_freeze: 
+    VACUUM FREEZE vestat;
+
+step s1_select_from_index: 
+    BEGIN;
+    SELECT count(x) FROM vestat WHERE x > 2000;
+
+count
+-----
+ 3000
+(1 row)
+
+step s2_delete_from_table: 
+    DELETE FROM vestat WHERE x > 4930;
+
+step s1_get_cleared_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT v.visible_page_marks_cleared > cleared_all_visibile_flag_count as visible_page_marks_cleared,
+           v.frozen_page_marks_cleared > cleared_frozen_flag_count as frozen_page_marks_cleared
+        FROM pg_stat_all_tables v, stats_state
+        WHERE v.relname = 'vestat';
+
+    UPDATE stats_state
+        SET cleared_all_visibile_flag_count = v.visible_page_marks_cleared,
+            cleared_frozen_flag_count = v.frozen_page_marks_cleared
+        FROM pg_stat_all_tables v
+        WHERE v.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+visible_page_marks_cleared|frozen_page_marks_cleared
+--------------------------+-------------------------
+f                         |f                        
+(1 row)
+
+step s2_vacuum_freeze: 
+    VACUUM FREEZE vestat;
+
+step s1_get_set_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT c.relallfrozen > frozen_flag_count as relallfrozen, c.relallvisible > all_visibile_flag_count as relallvisible
+        FROM pg_class c, stats_state
+        WHERE c.relname = 'vestat';
+
+    UPDATE stats_state
+        SET frozen_flag_count = c.relallfrozen,
+            all_visibile_flag_count = c.relallvisible
+        FROM pg_class c
+        WHERE c.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+relallfrozen|relallvisible
+------------+-------------
+f           |f            
+(1 row)
+
+step s1_commit: 
+    COMMIT;
+
+step s1_get_cleared_vm_flags_stats: 
+    SELECT pg_stat_force_next_flush();
+
+    SELECT v.visible_page_marks_cleared > cleared_all_visibile_flag_count as visible_page_marks_cleared,
+           v.frozen_page_marks_cleared > cleared_frozen_flag_count as frozen_page_marks_cleared
+        FROM pg_stat_all_tables v, stats_state
+        WHERE v.relname = 'vestat';
+
+    UPDATE stats_state
+        SET cleared_all_visibile_flag_count = v.visible_page_marks_cleared,
+            cleared_frozen_flag_count = v.frozen_page_marks_cleared
+        FROM pg_stat_all_tables v
+        WHERE v.relname = 'vestat';
+
+pg_stat_force_next_flush
+------------------------
+                        
+(1 row)
+
+visible_page_marks_cleared|frozen_page_marks_cleared
+--------------------------+-------------------------
+t                         |t                        
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index 15c33fad4c..3d59eef7a7 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -102,6 +102,7 @@ test: timeouts
 test: vacuum-concurrent-drop
 test: vacuum-conflict
 test: vacuum-skip-locked
+test: vacuum-extending-in-repetable-read
 test: stats
 test: horizons
 test: predicate-hash
@@ -127,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: vacuum-extending-freeze
diff --git a/src/test/isolation/specs/vacuum-extending-freeze.spec b/src/test/isolation/specs/vacuum-extending-freeze.spec
new file mode 100644
index 0000000000..17c204e232
--- /dev/null
+++ b/src/test/isolation/specs/vacuum-extending-freeze.spec
@@ -0,0 +1,117 @@
+# In short, this test validates the correctness and stability of cumulative
+# vacuum statistics accounting around freezing, visibility, and revision
+# tracking across VACUUM and backend operations.
+# In addition, the test provides a scenario where one process holds a
+# transaction open while another process deletes tuples. We expect that
+# a backend clears the all-frozen and all-visible flags, which were set
+# by VACUUM earlier, only after the committing transaction makes the
+# deletions visible.
+
+setup
+{
+    CREATE TABLE vestat (x int, y int)
+        WITH (autovacuum_enabled = off, fillfactor = 70);
+
+    INSERT INTO vestat
+        SELECT i, i FROM generate_series(1, 5000) AS g(i);
+
+    CREATE INDEX vestat_idx ON vestat (x);
+
+    CREATE TABLE stats_state (frozen_flag_count int, all_visibile_flag_count int,
+                        cleared_frozen_flag_count int, cleared_all_visibile_flag_count int);
+    INSERT INTO stats_state VALUES (0,0,0,0);
+    ANALYZE vestat;
+
+    -- Ensure stats are flushed before starting the scenario
+    SELECT pg_stat_force_next_flush();
+}
+
+teardown
+{
+    DROP TABLE IF EXISTS vestat;
+    RESET vacuum_freeze_min_age;
+    RESET vacuum_freeze_table_age;
+
+}
+
+session s1
+
+step s1_get_set_vm_flags_stats
+{
+    SELECT pg_stat_force_next_flush();
+
+    SELECT c.relallfrozen > frozen_flag_count as relallfrozen, c.relallvisible > all_visibile_flag_count as relallvisible
+        FROM pg_class c, stats_state
+        WHERE c.relname = 'vestat';
+
+    UPDATE stats_state
+        SET frozen_flag_count = c.relallfrozen,
+            all_visibile_flag_count = c.relallvisible
+        FROM pg_class c
+        WHERE c.relname = 'vestat';
+}
+
+step s1_get_cleared_vm_flags_stats
+{
+    SELECT pg_stat_force_next_flush();
+
+    SELECT v.visible_page_marks_cleared > cleared_all_visibile_flag_count as visible_page_marks_cleared,
+           v.frozen_page_marks_cleared > cleared_frozen_flag_count as frozen_page_marks_cleared
+        FROM pg_stat_all_tables v, stats_state
+        WHERE v.relname = 'vestat';
+
+    UPDATE stats_state
+        SET cleared_all_visibile_flag_count = v.visible_page_marks_cleared,
+            cleared_frozen_flag_count = v.frozen_page_marks_cleared
+        FROM pg_stat_all_tables v
+        WHERE v.relname = 'vestat';
+}
+
+step s1_select_from_index
+{
+    BEGIN;
+    SELECT count(x) FROM vestat WHERE x > 2000;
+}
+
+step s1_commit
+{
+    COMMIT;
+}
+
+session s2
+setup
+{
+    -- Configure aggressive freezing vacuum behavior
+    SET vacuum_freeze_min_age = 0;
+    SET vacuum_freeze_table_age = 0;
+}
+step s2_delete_from_table
+{
+    DELETE FROM vestat WHERE x > 4930;
+}
+step s2_vacuum_freeze
+{
+    VACUUM FREEZE vestat;
+}
+
+step s1_update_table
+{
+    UPDATE vestat SET x = x + 1001 where x >= 2500;
+    SELECT pg_stat_force_next_flush();
+}
+
+permutation
+    s2_vacuum_freeze
+    s1_get_set_vm_flags_stats
+    s1_update_table
+    s1_get_cleared_vm_flags_stats
+    s2_vacuum_freeze
+    s1_get_set_vm_flags_stats
+    s2_vacuum_freeze
+    s1_select_from_index
+    s2_delete_from_table
+    s1_get_cleared_vm_flags_stats
+    s2_vacuum_freeze
+    s1_get_set_vm_flags_stats
+    s1_commit
+    s1_get_cleared_vm_flags_stats
\ No newline at end of file
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index a65a5bf0c4..096e4f763f 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -1846,7 +1846,9 @@ pg_stat_all_tables| SELECT c.oid AS relid,
     pg_stat_get_total_autovacuum_time(c.oid) AS total_autovacuum_time,
     pg_stat_get_total_analyze_time(c.oid) AS total_analyze_time,
     pg_stat_get_total_autoanalyze_time(c.oid) AS total_autoanalyze_time,
-    pg_stat_get_stat_reset_time(c.oid) AS stats_reset
+    pg_stat_get_stat_reset_time(c.oid) AS stats_reset,
+    pg_stat_get_visible_page_marks_cleared(c.oid) AS visible_page_marks_cleared,
+    pg_stat_get_frozen_page_marks_cleared(c.oid) AS frozen_page_marks_cleared
    FROM ((pg_class c
      LEFT JOIN pg_index i ON ((c.oid = i.indrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace)))
@@ -2357,7 +2359,9 @@ pg_stat_sys_tables| SELECT relid,
     total_autovacuum_time,
     total_analyze_time,
     total_autoanalyze_time,
-    stats_reset
+    stats_reset,
+    visible_page_marks_cleared,
+    frozen_page_marks_cleared
    FROM pg_stat_all_tables
   WHERE ((schemaname = ANY (ARRAY['pg_catalog'::name, 'information_schema'::name])) OR (schemaname ~ '^pg_toast'::text));
 pg_stat_user_functions| SELECT p.oid AS funcid,
@@ -2412,7 +2416,9 @@ pg_stat_user_tables| SELECT relid,
     total_autovacuum_time,
     total_analyze_time,
     total_autoanalyze_time,
-    stats_reset
+    stats_reset,
+    visible_page_marks_cleared,
+    frozen_page_marks_cleared
    FROM pg_stat_all_tables
   WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text));
 pg_stat_wal| SELECT wal_records,
-- 
2.39.5 (Apple Git-154)



  [text/plain] 0002-Extended-vacuum-statistics-core-heap-and-tuple-metri.patch (49.3K, 4-0002-Extended-vacuum-statistics-core-heap-and-tuple-metri.patch)
  download | inline diff:
From 41e5aeac7c3443912e0e020816eb0cbb649c2dbb Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:06:53 +0300
Subject: [PATCH 2/8] Extended vacuum statistics: core heap and tuple metrics
 for tables and indexes

Expose the core per-table heap and dead-tuple metrics of the last
VACUUM through the new pg_stat_vacuum_tables view and
pg_stat_get_vacuum_tables() function, with documentation and regression
coverage:

  pages_scanned         heap pages examined (not skipped via the VM)
  pages_removed         heap pages by which storage was truncated
  tuples_deleted        dead tuples removed by the vacuum
  tuples_frozen         tuples frozen by the vacuum
  recently_dead_tuples  dead tuples still visible to some transaction and
                        therefore not yet removable

The matching per-index core metrics are exposed at the same time through the
new pg_stat_vacuum_indexes view and pg_stat_get_vacuum_indexes() function:

  pages_deleted   index pages deleted (made reusable) by the vacuum
  tuples_deleted  index entries removed by the vacuum

This first commit also adds the infrastructure the rest of the series builds
on.  Extended vacuum statistics are stored in the cumulative statistics system
under a dedicated stats kind, PGSTAT_KIND_VACUUM_RELATION, kept separate from
the regular relation statistics. They are collected during vacuum and
accumulated per relation, only while the track_vacuum_statistics GUC is
enabled.  Indexes are tracked under the same stats kind, with their totals
reported once per index at the end of the vacuum.

The vacuum-extending-in-repetable-read isolation test is added here to cover
recently_dead_tuples: a REPEATABLE READ transaction keeps recently deleted
tuples visible, so VACUUM cannot remove them and reports them as
recently_dead_tuples rather than tuples_deleted.
---
 doc/src/sgml/system-views.sgml                | 190 ++++++++++++++++++
 src/backend/access/heap/vacuumlazy.c          |  79 ++++++++
 src/backend/catalog/heap.c                    |   1 +
 src/backend/catalog/index.c                   |   1 +
 src/backend/catalog/system_views.sql          |  35 ++++
 src/backend/commands/vacuumparallel.c         |  27 +++
 src/backend/utils/activity/Makefile           |   1 +
 src/backend/utils/activity/meson.build        |   1 +
 src/backend/utils/activity/pgstat.c           |  15 +-
 src/backend/utils/activity/pgstat_relation.c  |   6 +
 src/backend/utils/activity/pgstat_vacuum.c    | 126 ++++++++++++
 src/backend/utils/adt/pgstatfuncs.c           |  84 ++++++++
 src/backend/utils/misc/guc_parameters.dat     |   6 +
 src/backend/utils/misc/postgresql.conf.sample |   1 +
 src/include/catalog/pg_proc.dat               |  19 ++
 src/include/pgstat.h                          |  83 +++++++-
 src/include/utils/pgstat_internal.h           |   8 +
 src/include/utils/pgstat_kind.h               |   3 +-
 .../vacuum-extending-in-repetable-read.out    |  38 ++++
 .../vacuum-extending-in-repetable-read.spec   |  53 +++++
 src/test/regress/expected/rules.out           |  25 +++
 src/test/regress/expected/vacuum_stats.out    |  87 ++++++++
 src/test/regress/parallel_schedule            |   3 +
 src/test/regress/sql/vacuum_stats.sql         |  68 +++++++
 24 files changed, 957 insertions(+), 3 deletions(-)
 create mode 100644 src/backend/utils/activity/pgstat_vacuum.c
 create mode 100644 src/test/isolation/expected/vacuum-extending-in-repetable-read.out
 create mode 100644 src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
 create mode 100644 src/test/regress/expected/vacuum_stats.out
 create mode 100644 src/test/regress/sql/vacuum_stats.sql

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 2ebec6928d..bdcd8d9f47 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5782,4 +5782,194 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
   </table>
  </sect1>
 
+ <sect1 id="view-pg-stat-vacuum-tables">
+  <title><structname>pg_stat_vacuum_tables</structname></title>
+
+  <indexterm zone="view-pg-stat-vacuum-tables">
+   <primary>pg_stat_vacuum_tables</primary>
+  </indexterm>
+
+  <para>
+   The <structname>pg_stat_vacuum_tables</structname> view will contain one row
+   for each table in the current database, showing extended statistics about
+   the activity of the most recent <command>VACUUM</command> on that table.
+   These statistics are accumulated only while
+   <varname>track_vacuum_statistics</varname> is enabled.
+  </para>
+
+  <table id="view-pg-stat-vacuum-tables-cols">
+   <title><structname>pg_stat_vacuum_tables</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>schemaname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the schema that the table is in.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the table.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the table.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_scanned</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages examined by the vacuum (pages that were not skipped using the visibility map).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_removed</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages by which the table's storage was physically reduced (truncated) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of dead tuples removed by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_frozen</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of tuples frozen by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>recently_dead_tuples</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of dead tuples that are still visible to some transaction and therefore could not yet be removed.
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
+ <sect1 id="view-pg-stat-vacuum-indexes">
+  <title><structname>pg_stat_vacuum_indexes</structname></title>
+
+  <indexterm zone="view-pg-stat-vacuum-indexes">
+   <primary>pg_stat_vacuum_indexes</primary>
+  </indexterm>
+
+  <para>
+   The <structname>pg_stat_vacuum_indexes</structname> view will contain one row
+   for each index in the current database, showing extended statistics about
+   the activity of the most recent <command>VACUUM</command> on that index.
+   These statistics are accumulated only while
+   <varname>track_vacuum_statistics</varname> is enabled.
+  </para>
+
+  <table id="view-pg-stat-vacuum-indexes-cols">
+   <title><structname>pg_stat_vacuum_indexes</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the table the index is on.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>indexrelid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>schemaname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the schema that the table is in.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the table.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>indexrelname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of index pages deleted (made reusable) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of index entries removed by the vacuum.
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 39395aed0d..3c403e86f0 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -283,6 +283,7 @@ typedef struct LVRelState
 	/* Error reporting state */
 	char	   *dbname;
 	char	   *relnamespace;
+	Oid			reloid;
 	char	   *relname;
 	char	   *indname;		/* Current index name */
 	BlockNumber blkno;			/* used only for heap operations */
@@ -483,6 +484,8 @@ static void update_vacuum_error_info(LVRelState *vacrel,
 									 OffsetNumber offnum);
 static void restore_vacuum_error_info(LVRelState *vacrel,
 									  const LVSavedErrInfo *saved_vacrel);
+static void accumulate_heap_vacuum_statistics(LVRelState *vacrel,
+											  PgStat_VacuumRelationCounts *extVacStats);
 
 
 
@@ -609,6 +612,64 @@ heap_vacuum_eager_scan_setup(LVRelState *vacrel, const VacuumParams *params)
 		first_region_ratio;
 }
 
+/*
+ * Fill the extended vacuum statistics report for a heap relation with the
+ * counters that are derived directly from the LVRelState.  Buffer, WAL and
+ * timing counters require sampling resource usage around the vacuum and are
+ * gathered separately; they are not touched here.
+ */
+static void
+accumulate_heap_vacuum_statistics(LVRelState *vacrel,
+								  PgStat_VacuumRelationCounts *extVacStats)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	extVacStats->type = PGSTAT_EXTVAC_TABLE;
+	extVacStats->table.pages_scanned = vacrel->scanned_pages;
+	extVacStats->table.pages_removed = vacrel->removed_pages;
+	extVacStats->common.tuples_deleted = vacrel->tuples_deleted;
+	extVacStats->table.tuples_frozen = vacrel->tuples_frozen;
+	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
+}
+
+/*
+ * Report the per-index extended vacuum statistics, one report per index.
+ *
+ * The per-index counters (pages_deleted and the number of removed index
+ * entries) are derived directly from each index's final IndexBulkDeleteResult,
+ * which already holds the totals accumulated across all bulkdelete and cleanup
+ * passes -- so no per-pass sampling is needed here.  Used by the non-parallel
+ * path only; the parallel path reports its DSM-resident results from
+ * parallel_vacuum_end().
+ */
+static void
+report_index_vacuum_extstats(LVRelState *vacrel)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	for (int idx = 0; idx < vacrel->nindexes; idx++)
+	{
+		Relation	indrel = vacrel->indrels[idx];
+		IndexBulkDeleteResult *istat = vacrel->indstats[idx];
+		PgStat_VacuumRelationCounts report;
+
+		/* Skip indexes that this vacuum did not process */
+		if (istat == NULL)
+			continue;
+
+		memset(&report, 0, sizeof(report));
+		report.type = PGSTAT_EXTVAC_INDEX;
+		report.common.tuples_deleted = istat->tuples_removed;
+		report.index.pages_deleted = istat->pages_deleted;
+
+		pgstat_report_vacuum_extstats(RelationGetRelid(indrel),
+									  indrel->rd_rel->relisshared,
+									  &report);
+	}
+}
+
 /*
  *	heap_vacuum_rel() -- perform VACUUM for one heap relation
  *
@@ -643,6 +704,10 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	ErrorContextCallback errcallback;
 	char	  **indnames = NULL;
 	Size		dead_items_max_bytes = 0;
+	PgStat_VacuumRelationCounts extVacReport;
+
+	/* Initialize the extended vacuum statistics report */
+	memset(&extVacReport, 0, sizeof(PgStat_VacuumRelationCounts));
 
 	verbose = (params->options & VACOPT_VERBOSE) != 0;
 	instrument = (verbose || (AmAutoVacuumWorkerProcess() &&
@@ -686,6 +751,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel = palloc0_object(LVRelState);
 	vacrel->dbname = get_database_name(MyDatabaseId);
 	vacrel->relnamespace = get_namespace_name(RelationGetNamespace(rel));
+	vacrel->reloid = RelationGetRelid(rel);
 	vacrel->relname = pstrdup(RelationGetRelationName(rel));
 	vacrel->indname = NULL;
 	vacrel->phase = VACUUM_ERRCB_PHASE_UNKNOWN;
@@ -700,6 +766,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vac_open_indexes(vacrel->rel, RowExclusiveLock, &vacrel->nindexes,
 					 &vacrel->indrels);
 	vacrel->bstrategy = bstrategy;
+
 	if (instrument && vacrel->nindexes > 0)
 	{
 		/* Copy index names used by instrumentation (not error reporting) */
@@ -985,6 +1052,9 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	 * soon in cases where the failsafe prevented significant amounts of heap
 	 * vacuuming.
 	 */
+	accumulate_heap_vacuum_statistics(vacrel, &extVacReport);
+	pgstat_report_vacuum_extstats(vacrel->reloid, rel->rd_rel->relisshared,
+								  &extVacReport);
 	pgstat_report_vacuum(rel,
 						 Max(vacrel->new_live_tuples, 0),
 						 vacrel->recently_dead_tuples +
@@ -1618,6 +1688,15 @@ lazy_scan_heap(LVRelState *vacrel)
 	/* Do final index cleanup (call each index's amvacuumcleanup routine) */
 	if (vacrel->nindexes > 0 && vacrel->do_index_cleanup)
 		lazy_cleanup_all_indexes(vacrel);
+
+	/*
+	 * Report the per-index extended vacuum statistics accumulated over all
+	 * bulkdelete and cleanup passes, exactly once per index.  The parallel
+	 * path reports its DSM-resident totals from parallel_vacuum_end() instead,
+	 * so only do it here when index vacuuming ran in the leader.
+	 */
+	if (vacrel->nindexes > 0 && !ParallelVacuumIsActive(vacrel))
+		report_index_vacuum_extstats(vacrel);
 }
 
 /*
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index 88087654de..709fe33e61 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -1903,6 +1903,7 @@ heap_drop_with_catalog(Oid relid)
 
 	/* ensure that stats are dropped if transaction commits */
 	pgstat_drop_relation(rel);
+	pgstat_vacuum_relation_delete_pending_cb(RelationGetRelid(rel));
 
 	/*
 	 * Close relcache entry, but *keep* AccessExclusiveLock on the relation
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 9407c357f2..f986276677 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2345,6 +2345,7 @@ index_drop(Oid indexId, bool concurrent, bool concurrent_lock_mode)
 
 	/* ensure that stats are dropped if transaction commits */
 	pgstat_drop_relation(userIndexRelation);
+	pgstat_vacuum_relation_delete_pending_cb(RelationGetRelid(userIndexRelation));
 
 	/*
 	 * Close and flush the index's relcache entry, to ensure relcache doesn't
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index b57128bb12..01487f1665 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1559,3 +1559,38 @@ CREATE VIEW pg_aios AS
     SELECT * FROM pg_get_aios();
 REVOKE ALL ON pg_aios FROM PUBLIC;
 GRANT SELECT ON pg_aios TO pg_read_all_stats;
+
+CREATE VIEW pg_stat_vacuum_tables AS
+    SELECT
+        N.nspname AS schemaname,
+        C.relname AS relname,
+        S.relid AS relid,
+
+        S.pages_scanned AS pages_scanned,
+        S.pages_removed AS pages_removed,
+        S.tuples_deleted AS tuples_deleted,
+        S.tuples_frozen AS tuples_frozen,
+        S.recently_dead_tuples AS recently_dead_tuples
+
+    FROM pg_class C JOIN
+            pg_namespace N ON N.oid = C.relnamespace,
+            LATERAL pg_stat_get_vacuum_tables(C.oid) S
+    WHERE C.relkind IN ('r', 't', 'm');
+
+CREATE VIEW pg_stat_vacuum_indexes AS
+    SELECT
+            C.oid AS relid,
+            I.oid AS indexrelid,
+            N.nspname AS schemaname,
+            C.relname AS relname,
+            I.relname AS indexrelname,
+
+            S.pages_deleted AS pages_deleted,
+            S.tuples_deleted AS tuples_deleted
+    FROM
+            pg_class C JOIN
+            pg_index X ON C.oid = X.indrelid JOIN
+            pg_class I ON I.oid = X.indexrelid
+            LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace),
+            LATERAL pg_stat_get_vacuum_indexes(I.oid) S
+    WHERE C.relkind IN ('r', 't', 'm');
diff --git a/src/backend/commands/vacuumparallel.c b/src/backend/commands/vacuumparallel.c
index 41cefcfde5..7725c4ecc1 100644
--- a/src/backend/commands/vacuumparallel.c
+++ b/src/backend/commands/vacuumparallel.c
@@ -535,6 +535,33 @@ parallel_vacuum_end(ParallelVacuumState *pvs, IndexBulkDeleteResult **istats)
 	DestroyParallelContext(pvs->pcxt);
 	ExitParallelMode();
 
+	/*
+	 * Report the per-index extended vacuum statistics, one report per index,
+	 * derived directly from each index's final IndexBulkDeleteResult.  The
+	 * indexes are still open here (pvs->indrels is the leader's own array, not
+	 * in the now-destroyed DSM).
+	 */
+	if (pgstat_track_vacuum_statistics)
+	{
+		for (int i = 0; i < pvs->nindexes; i++)
+		{
+			Relation	indrel = pvs->indrels[i];
+			PgStat_VacuumRelationCounts report;
+
+			if (istats[i] == NULL)
+				continue;
+
+			memset(&report, 0, sizeof(report));
+			report.type = PGSTAT_EXTVAC_INDEX;
+			report.common.tuples_deleted = istats[i]->tuples_removed;
+			report.index.pages_deleted = istats[i]->pages_deleted;
+
+			pgstat_report_vacuum_extstats(RelationGetRelid(indrel),
+										  indrel->rd_rel->relisshared,
+										  &report);
+		}
+	}
+
 	if (AmAutoVacuumWorkerProcess())
 		pv_shared_cost_params = NULL;
 
diff --git a/src/backend/utils/activity/Makefile b/src/backend/utils/activity/Makefile
index ca3ef89bf5..b7db9c034a 100644
--- a/src/backend/utils/activity/Makefile
+++ b/src/backend/utils/activity/Makefile
@@ -28,6 +28,7 @@ OBJS = \
 	pgstat_io.o \
 	pgstat_lock.o \
 	pgstat_relation.o \
+	pgstat_vacuum.o \
 	pgstat_replslot.o \
 	pgstat_shmem.o \
 	pgstat_slru.o \
diff --git a/src/backend/utils/activity/meson.build b/src/backend/utils/activity/meson.build
index 1aa7ece529..2a0b50d07d 100644
--- a/src/backend/utils/activity/meson.build
+++ b/src/backend/utils/activity/meson.build
@@ -17,6 +17,7 @@ backend_sources += files(
   'pgstat_shmem.c',
   'pgstat_slru.c',
   'pgstat_subscription.c',
+  'pgstat_vacuum.c',
   'pgstat_wal.c',
   'pgstat_xact.c',
 )
diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index b67da88c7d..4e950672e2 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -204,7 +204,7 @@ static inline bool pgstat_is_kind_valid(PgStat_Kind kind);
 
 bool		pgstat_track_counts = false;
 int			pgstat_fetch_consistency = PGSTAT_FETCH_CONSISTENCY_CACHE;
-
+bool		pgstat_track_vacuum_statistics = false;
 
 /* ----------
  * state shared with pgstat_*.c
@@ -500,6 +500,19 @@ static const PgStat_KindInfo pgstat_kind_builtin_infos[PGSTAT_KIND_BUILTIN_SIZE]
 		.reset_all_cb = pgstat_wal_reset_all_cb,
 		.snapshot_cb = pgstat_wal_snapshot_cb,
 	},
+	[PGSTAT_KIND_VACUUM_RELATION] = {
+		.name = "vacuum statistics",
+
+		.fixed_amount = false,
+		.write_to_file = true,
+
+		.shared_size = sizeof(PgStatShared_VacuumRelation),
+		.shared_data_off = offsetof(PgStatShared_VacuumRelation, stats),
+		.shared_data_len = sizeof(((PgStatShared_VacuumRelation *) 0)->stats),
+		.pending_size = sizeof(PgStat_RelationVacuumPending),
+
+		.flush_pending_cb = pgstat_vacuum_relation_flush_cb
+	},
 };
 
 /*
diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c
index 92e1f60a08..141e2af607 100644
--- a/src/backend/utils/activity/pgstat_relation.c
+++ b/src/backend/utils/activity/pgstat_relation.c
@@ -904,6 +904,12 @@ pgstat_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	return true;
 }
 
+void
+pgstat_vacuum_relation_delete_pending_cb(Oid relid)
+{
+	pgstat_drop_transactional(PGSTAT_KIND_VACUUM_RELATION, relid, InvalidOid);
+}
+
 void
 pgstat_relation_delete_pending_cb(PgStat_EntryRef *entry_ref)
 {
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
new file mode 100644
index 0000000000..5a625132dd
--- /dev/null
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -0,0 +1,126 @@
+/* -------------------------------------------------------------------------
+ *
+ * pgstat_vacuum.c
+ *	  Implementation of extended vacuum statistics.
+ *
+ * This file contains the implementation of extended vacuum statistics. It is
+ * kept separate from pgstat_relation.c and pgstat_database.c to reduce the
+ * memory footprint of the regular relation and database statistics: vacuum
+ * metrics require significantly more space per relation, so they live in their
+ * own PGSTAT_KIND_VACUUM_RELATION stats kind.
+ *
+ * Copyright (c) 2001-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/utils/activity/pgstat_vacuum.c
+ * -------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "pgstat.h"
+#include "utils/memutils.h"
+#include "utils/pgstat_internal.h"
+
+#define ACCUMULATE_SUBFIELD(substruct, field) (dst->substruct.field += src->substruct.field)
+
+/*
+ * Accumulate the per-table extended vacuum counters collected so far.
+ *
+ * Only the counters derived directly from the vacuum's own bookkeeping are
+ * summed here.  The buffer, WAL and timing counters (and the per-index
+ * counters) are accumulated by additional code added together with the
+ * helpers that gather them.
+ */
+static void
+pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
+										 PgStat_VacuumRelationCounts *src)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	if (dst->type == PGSTAT_EXTVAC_INVALID)
+		dst->type = src->type;
+
+	Assert(src->type != PGSTAT_EXTVAC_INVALID &&
+		   src->type != PGSTAT_EXTVAC_DB &&
+		   src->type == dst->type);
+
+	ACCUMULATE_SUBFIELD(common, tuples_deleted);
+
+	if (dst->type == PGSTAT_EXTVAC_TABLE)
+	{
+		ACCUMULATE_SUBFIELD(table, pages_scanned);
+		ACCUMULATE_SUBFIELD(table, pages_removed);
+		ACCUMULATE_SUBFIELD(table, tuples_frozen);
+		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
+	}
+	else if (dst->type == PGSTAT_EXTVAC_INDEX)
+	{
+		ACCUMULATE_SUBFIELD(index, pages_deleted);
+	}
+}
+
+/*
+ * Report that the relation was just vacuumed, accumulating its extended
+ * statistics into the per-relation entry.
+ */
+void
+pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
+							  PgStat_VacuumRelationCounts *params)
+{
+	PgStat_EntryRef *entry_ref;
+	PgStatShared_VacuumRelation *shtabentry;
+	Oid			dboid = (shared ? InvalidOid : MyDatabaseId);
+
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_VACUUM_RELATION,
+											dboid, tableoid, false);
+	shtabentry = (PgStatShared_VacuumRelation *) entry_ref->shared_stats;
+	pgstat_accumulate_extvac_stats_relations(&shtabentry->stats, params);
+	pgstat_unlock_entry(entry_ref);
+}
+
+/*
+ * Flush out pending per-relation extended vacuum stats for the entry.
+ *
+ * If nowait is true, this function returns false if the lock could not be
+ * acquired immediately, otherwise true is returned.
+ */
+bool
+pgstat_vacuum_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
+{
+	PgStatShared_VacuumRelation *shtabstats;
+	PgStat_RelationVacuumPending *pendingent;
+
+	pendingent = (PgStat_RelationVacuumPending *) entry_ref->pending;
+	shtabstats = (PgStatShared_VacuumRelation *) entry_ref->shared_stats;
+
+	/* Ignore entries that didn't accumulate any actual counts. */
+	if (pg_memory_is_all_zeros(pendingent,
+							   sizeof(PgStat_RelationVacuumPending)))
+		return true;
+
+	if (!pgstat_lock_entry(entry_ref, nowait))
+		return false;
+
+	pgstat_accumulate_extvac_stats_relations(&shtabstats->stats,
+											 &pendingent->counts);
+
+	pgstat_unlock_entry(entry_ref);
+
+	return true;
+}
+
+/*
+ * Support function for the SQL-callable pgstat* functions. Returns the vacuum
+ * collected statistics for one relation or NULL.
+ */
+PgStat_VacuumRelationCounts *
+pgstat_fetch_stat_vacuum_tabentry(Oid relid, Oid dbid)
+{
+	return (PgStat_VacuumRelationCounts *)
+		pgstat_fetch_entry(PGSTAT_KIND_VACUUM_RELATION, dbid, relid, NULL);
+}
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 6409987d66..d6927d945d 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2367,3 +2367,87 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 
 	PG_RETURN_BOOL(pgstat_have_entry(kind, dboid, objid));
 }
+
+/*
+ * Get the extended vacuum statistics for a heap table.
+ */
+Datum
+pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
+{
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 6
+
+	Oid			relid = PG_GETARG_OID(0);
+	PgStat_VacuumRelationCounts *extvacuum;
+	TupleDesc	tupdesc;
+	Datum		values[PG_STAT_GET_VACUUM_TABLES_STATS_COLS] = {0};
+	bool		nulls[PG_STAT_GET_VACUUM_TABLES_STATS_COLS] = {0};
+	int			i = 0;
+
+	/* Build a tuple descriptor for our result type */
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "return type must be a row type");
+
+	extvacuum = pgstat_fetch_stat_vacuum_tabentry(relid, MyDatabaseId);
+	if (!extvacuum)
+	{
+		/* Retry as a shared relation before giving up. */
+		extvacuum = pgstat_fetch_stat_vacuum_tabentry(relid, InvalidOid);
+		if (!extvacuum)
+		{
+			InitMaterializedSRF(fcinfo, 0);
+			PG_RETURN_VOID();
+		}
+	}
+
+	values[i++] = ObjectIdGetDatum(relid);
+	values[i++] = Int64GetDatum(extvacuum->table.pages_scanned);
+	values[i++] = Int64GetDatum(extvacuum->table.pages_removed);
+	values[i++] = Int64GetDatum(extvacuum->common.tuples_deleted);
+	values[i++] = Int64GetDatum(extvacuum->table.tuples_frozen);
+	values[i++] = Int64GetDatum(extvacuum->table.recently_dead_tuples);
+
+	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
+
+	/* Returns the record as Datum */
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+}
+
+/*
+ * Get the extended vacuum statistics for an index.
+ */
+Datum
+pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
+{
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 3
+
+	Oid			relid = PG_GETARG_OID(0);
+	PgStat_VacuumRelationCounts *extvacuum;
+	TupleDesc	tupdesc;
+	Datum		values[PG_STAT_GET_VACUUM_INDEX_STATS_COLS] = {0};
+	bool		nulls[PG_STAT_GET_VACUUM_INDEX_STATS_COLS] = {0};
+	int			i = 0;
+
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "return type must be a row type");
+
+	extvacuum = pgstat_fetch_stat_vacuum_tabentry(relid, MyDatabaseId);
+	if (!extvacuum)
+	{
+		extvacuum = pgstat_fetch_stat_vacuum_tabentry(relid, InvalidOid);
+		if (!extvacuum)
+		{
+			InitMaterializedSRF(fcinfo, 0);
+			PG_RETURN_VOID();
+		}
+	}
+
+	values[i++] = ObjectIdGetDatum(relid);
+
+	values[i++] = Int64GetDatum(extvacuum->index.pages_deleted);
+	values[i++] = Int64GetDatum(extvacuum->common.tuples_deleted);
+
+	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
+
+	/* Returns the record as Datum */
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+}
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index afaa058b04..bb8eb41394 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -3220,6 +3220,12 @@
   boot_val => 'false',
 },
 
+{ name => 'track_vacuum_statistics', type => 'bool', context => 'PGC_SUSET', group => 'STATS_CUMULATIVE',
+  short_desc => 'Collects vacuum statistics for vacuum activity.',
+  variable => 'pgstat_track_vacuum_statistics',
+  boot_val => 'false',
+},
+
 { name => 'track_wal_io_timing', type => 'bool', context => 'PGC_SUSET', group => 'STATS_CUMULATIVE',
   short_desc => 'Collects timing statistics for WAL I/O activity.',
   variable => 'track_wal_io_timing',
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index ac38cddaaf..4cf28f0f2e 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -702,6 +702,7 @@
 #track_wal_io_timing = off
 #track_functions = none                 # none, pl, all
 #stats_fetch_consistency = cache        # cache, none, snapshot
+#track_vacuum_statistics = off
 
 
 # - Monitoring -
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 291b039859..6bc3bd909b 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12638,6 +12638,16 @@
   proargnames => '{pid,io_id,io_generation,state,operation,off,length,target,handle_data_len,raw_result,result,target_desc,f_sync,f_localmem,f_buffered}',
   prosrc => 'pg_get_aios', proacl => '{POSTGRES=X,pg_read_all_stats=X}' },
 
+{ oid => '8001',
+  descr => 'pg_stat_get_vacuum_tables returns vacuum stats values for table',
+  proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
+  proretset => 't',
+  proargtypes => 'oid',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples}',
+  prosrc => 'pg_stat_get_vacuum_tables' }
+
 # oid8 related functions
 { oid => '6436', descr => 'convert oid to oid8',
   proname => 'oid8', prorettype => 'oid8', proargtypes => 'oid',
@@ -12703,4 +12713,13 @@
   proname => 'pg_stat_get_frozen_page_marks_cleared', provolatile => 's',
   proparallel => 'r', prorettype => 'int8', proargtypes => 'oid',
   prosrc => 'pg_stat_get_frozen_page_marks_cleared' },
+{ oid => '8004',
+  descr => 'pg_stat_get_vacuum_indexes returns vacuum stats values for index',
+  proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
+  proretset => 't',
+  proargtypes => 'oid',
+  proallargtypes => '{oid,oid,int8,int8}',
+  proargmodes => '{i,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted}',
+  prosrc => 'pg_stat_get_vacuum_indexes' },
 ]
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index 7db36cf8ad..404e7d0297 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -118,6 +118,15 @@ typedef struct PgStat_BackendSubEntry
 	PgStat_Counter conflict_count[CONFLICT_NUM_TYPES];
 } PgStat_BackendSubEntry;
 
+/* Type of ExtVacReport */
+typedef enum ExtVacReportType
+{
+	PGSTAT_EXTVAC_INVALID = 0,
+	PGSTAT_EXTVAC_TABLE = 1,
+	PGSTAT_EXTVAC_INDEX = 2,
+	PGSTAT_EXTVAC_DB = 3,
+}			ExtVacReportType;
+
 /* ----------
  * PgStat_TableCounts			The actual per-table counts kept by a backend
  *
@@ -164,6 +173,66 @@ typedef struct PgStat_TableCounts
 	PgStat_Counter frozen_page_marks_cleared;
 } PgStat_TableCounts;
 
+typedef struct PgStat_CommonCounts
+{
+	/* tuples */
+	int64		tuples_deleted;
+}			PgStat_CommonCounts;
+
+/* ----------
+ *
+ * PgStat_VacuumRelationCounts
+ *
+ * Additional statistics of vacuum processing over a relation.  Counters that
+ * require sampling buffer/WAL/timing usage, and the per-index counters, are
+ * added to the common and per-type members later, together with the code that
+ * gathers them.
+ * ----------
+ */
+typedef struct PgStat_VacuumRelationCounts
+{
+	PgStat_CommonCounts common;
+
+	ExtVacReportType type;		/* heap, index, etc. */
+
+	/* ----------
+	 *
+	 * There are separate metrics of statistic for tables and indexes,
+	 * which collect during vacuum.
+	 * The union operator allows to combine these statistics
+	 * so that each metric is assigned to a specific class of collected statistics.
+	 * Such a combined structure was called per_type_stats.
+	 * The name of the structure itself is not used anywhere,
+	 * it exists only for understanding the code.
+	 * ----------
+	*/
+	union
+	{
+		struct
+		{
+			int64		tuples_frozen;	/* tuples frozen up by vacuum */
+			int64		recently_dead_tuples;	/* deleted tuples that are
+												 * still visible to some
+												 * transaction */
+			int64		pages_scanned;	/* heap pages examined (not skipped by
+										 * VM) */
+			int64		pages_removed;	/* heap pages removed by vacuum
+										 * "truncation" */
+		}			table;
+		struct
+		{
+			int64		pages_deleted;	/* number of pages deleted by vacuum */
+		}			index;
+	} /* per_type_stats */ ;
+}			PgStat_VacuumRelationCounts;
+
+typedef struct PgStat_VacuumRelationStatus
+{
+	Oid			id;				/* table's OID */
+	bool		shared;			/* is it a shared catalog? */
+	PgStat_VacuumRelationCounts counts; /* event counts to be sent */
+}			PgStat_VacuumRelationStatus;
+
 /* ----------
  * PgStat_TableStatus			Per-table status within a backend
  *
@@ -188,6 +257,12 @@ typedef struct PgStat_TableStatus
 	Relation	relation;		/* rel that is using this entry */
 } PgStat_TableStatus;
 
+typedef struct PgStat_RelationVacuumPending
+{
+	Oid			id;				/* table's OID */
+	PgStat_VacuumRelationCounts counts; /* event counts to be sent */
+}			PgStat_RelationVacuumPending;
+
 /* ----------
  * PgStat_TableXactStatus		Per-table, per-subtransaction status
  * ----------
@@ -838,6 +913,12 @@ extern int	pgstat_get_transactional_drops(bool isCommit, struct xl_xact_stats_it
 extern void pgstat_execute_transactional_drops(int ndrops, struct xl_xact_stats_item *items, bool is_redo);
 
 
+extern void pgstat_vacuum_relation_delete_pending_cb(Oid relid);
+extern void
+			pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
+										  PgStat_VacuumRelationCounts * params);
+extern PgStat_VacuumRelationCounts * pgstat_fetch_stat_vacuum_tabentry(Oid relid, Oid dbid);
+
 /*
  * Functions in pgstat_wal.c
  */
@@ -854,7 +935,7 @@ extern PgStat_WalStats *pgstat_fetch_stat_wal(void);
 extern PGDLLIMPORT bool pgstat_track_counts;
 extern PGDLLIMPORT int pgstat_track_functions;
 extern PGDLLIMPORT int pgstat_fetch_consistency;
-
+extern PGDLLIMPORT bool pgstat_track_vacuum_statistics;
 
 /*
  * Variables in pgstat_bgwriter.c
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index fe463faaf6..46a127af2b 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -507,6 +507,12 @@ typedef struct PgStatShared_Relation
 	PgStat_StatTabEntry stats;
 } PgStatShared_Relation;
 
+typedef struct PgStatShared_VacuumRelation
+{
+	PgStatShared_Common header;
+	PgStat_VacuumRelationCounts stats;
+}			PgStatShared_VacuumRelation;
+
 typedef struct PgStatShared_Function
 {
 	PgStatShared_Common header;
@@ -689,6 +695,8 @@ extern void *pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid,
 								bool *may_free);
 extern void pgstat_snapshot_fixed(PgStat_Kind kind);
 
+extern bool pgstat_vacuum_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
+
 
 /*
  * Functions in pgstat_archiver.c
diff --git a/src/include/utils/pgstat_kind.h b/src/include/utils/pgstat_kind.h
index 2d78a02968..f92149066c 100644
--- a/src/include/utils/pgstat_kind.h
+++ b/src/include/utils/pgstat_kind.h
@@ -39,9 +39,10 @@
 #define PGSTAT_KIND_LOCK	11
 #define PGSTAT_KIND_SLRU	12
 #define PGSTAT_KIND_WAL	13
+#define PGSTAT_KIND_VACUUM_RELATION	14
 
 #define PGSTAT_KIND_BUILTIN_MIN PGSTAT_KIND_DATABASE
-#define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_WAL
+#define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_VACUUM_RELATION
 #define PGSTAT_KIND_BUILTIN_SIZE (PGSTAT_KIND_BUILTIN_MAX + 1)
 
 /* Custom stats kinds */
diff --git a/src/test/isolation/expected/vacuum-extending-in-repetable-read.out b/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
new file mode 100644
index 0000000000..a9d977c520
--- /dev/null
+++ b/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
@@ -0,0 +1,38 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin_repeatable_read s2_delete s2_vacuum s2_print_vacuum_stats_table s1_commit s2_vacuum s2_print_vacuum_stats_table
+step s1_begin_repeatable_read: 
+  BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+  SELECT count(*) FROM test_vacuum_stat_isolation;
+
+count
+-----
+   50
+(1 row)
+
+step s2_delete: DELETE FROM test_vacuum_stat_isolation WHERE id <= 10;
+step s2_vacuum: VACUUM test_vacuum_stat_isolation;
+step s2_print_vacuum_stats_table: 
+    SELECT
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    FROM pg_stat_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+
+relname                   |tuples_deleted|recently_dead_tuples
+--------------------------+--------------+--------------------
+test_vacuum_stat_isolation|             0|                  10
+(1 row)
+
+step s1_commit: COMMIT;
+step s2_vacuum: VACUUM test_vacuum_stat_isolation;
+step s2_print_vacuum_stats_table: 
+    SELECT
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    FROM pg_stat_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+
+relname                   |tuples_deleted|recently_dead_tuples
+--------------------------+--------------+--------------------
+test_vacuum_stat_isolation|            10|                  10
+(1 row)
+
diff --git a/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec b/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
new file mode 100644
index 0000000000..50b79715f3
--- /dev/null
+++ b/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
@@ -0,0 +1,53 @@
+# Test for recently_dead_tuples in pg_stat_vacuum_tables.
+#
+# A tuple deleted while an older snapshot can still see it is counted as
+# recently_dead_tuples, because VACUUM is not allowed to remove it yet; once
+# the older snapshot is gone, the next VACUUM removes it and counts it in
+# tuples_deleted instead.
+
+setup
+{
+    CREATE TABLE test_vacuum_stat_isolation (id int, ival int)
+      WITH (autovacuum_enabled = off);
+    INSERT INTO test_vacuum_stat_isolation
+      SELECT i, i FROM generate_series(1, 50) i;
+    SET track_vacuum_statistics TO 'on';
+}
+
+teardown
+{
+    DROP TABLE test_vacuum_stat_isolation CASCADE;
+    RESET track_vacuum_statistics;
+}
+
+# Reader holding an old snapshot, so deleted tuples stay recently dead:
+session s1
+setup		{ SET track_vacuum_statistics TO 'on'; }
+step s1_begin_repeatable_read
+{
+  BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+  SELECT count(*) FROM test_vacuum_stat_isolation;
+}
+step s1_commit			{ COMMIT; }
+
+# Performs the DML, the vacuums and prints the collected statistics:
+session s2
+setup		{ SET track_vacuum_statistics TO 'on'; }
+step s2_delete			{ DELETE FROM test_vacuum_stat_isolation WHERE id <= 10; }
+step s2_vacuum			{ VACUUM test_vacuum_stat_isolation; }
+step s2_print_vacuum_stats_table
+{
+    SELECT
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    FROM pg_stat_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+}
+
+permutation
+    s1_begin_repeatable_read
+    s2_delete
+    s2_vacuum
+    s2_print_vacuum_stats_table
+    s1_commit
+    s2_vacuum
+    s2_print_vacuum_stats_table
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 096e4f763f..40c6aca96e 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2421,6 +2421,31 @@ pg_stat_user_tables| SELECT relid,
     frozen_page_marks_cleared
    FROM pg_stat_all_tables
   WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text));
+pg_stat_vacuum_indexes| SELECT c.oid AS relid,
+    i.oid AS indexrelid,
+    n.nspname AS schemaname,
+    c.relname,
+    i.relname AS indexrelname,
+    s.pages_deleted,
+    s.tuples_deleted
+   FROM (((pg_class c
+     JOIN pg_index x ON ((c.oid = x.indrelid)))
+     JOIN pg_class i ON ((i.oid = x.indexrelid)))
+     LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted)
+  WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
+pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
+    c.relname,
+    s.relid,
+    s.pages_scanned,
+    s.pages_removed,
+    s.tuples_deleted,
+    s.tuples_frozen,
+    s.recently_dead_tuples
+   FROM (pg_class c
+     JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples)
+  WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
     wal_bytes,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
new file mode 100644
index 0000000000..c3079e3379
--- /dev/null
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -0,0 +1,87 @@
+--
+-- Extended vacuum statistics views (pg_stat_vacuum_tables, _indexes, _database)
+--
+SET track_vacuum_statistics = on;
+CREATE TABLE vacstat_t (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_t SELECT g, repeat('x', 20) FROM generate_series(1, 1000) g;
+DELETE FROM vacstat_t WHERE id % 2 = 0;
+VACUUM vacstat_t;
+-- core heap-page and dead-tuple metrics.  This VACUUM runs without concurrent
+-- activity: every deleted tuple is fully removable (recently_dead_tuples = 0),
+-- the surviving tuples are too fresh to be frozen (tuples_frozen = 0), and the
+-- interleaved deletions leave no trailing empty pages to truncate
+-- (pages_removed = 0).  The recently_dead_tuples non-zero path is covered by
+-- the vacuum-extending-in-repetable-read isolation test.
+SELECT pages_scanned > 0 AS pages_scanned,
+       pages_removed = 0 AS pages_removed,
+       tuples_deleted = 500 AS tuples_deleted,
+       tuples_frozen = 0 AS tuples_frozen,
+       recently_dead_tuples = 0 AS recently_dead_tuples
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ pages_scanned | pages_removed | tuples_deleted | tuples_frozen | recently_dead_tuples 
+---------------+---------------+----------------+---------------+----------------------
+ t             | t             | t              | t             | t
+(1 row)
+
+-- pages_removed path: deleting every tuple lets VACUUM truncate the now-empty
+-- trailing heap pages, so pages_removed advances.
+CREATE TABLE vacstat_trunc (id int)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_trunc SELECT generate_series(1, 10000);
+DELETE FROM vacstat_trunc;
+VACUUM vacstat_trunc;
+SELECT pages_removed > 0 AS pages_removed,
+       tuples_deleted = 10000 AS tuples_deleted
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_trunc';
+ pages_removed | tuples_deleted 
+---------------+----------------
+ t             | t
+(1 row)
+
+DROP TABLE vacstat_trunc;
+-- tuples_frozen path: an aggressive VACUUM (FREEZE) freezes all live tuples,
+-- so tuples_frozen advances.
+CREATE TABLE vacstat_freeze (x int)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_freeze SELECT generate_series(1, 1000);
+VACUUM (FREEZE) vacstat_freeze;
+SELECT tuples_frozen > 0 AS tuples_frozen
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_freeze';
+ tuples_frozen 
+---------------
+ t
+(1 row)
+
+DROP TABLE vacstat_freeze;
+-- per-index view: the primary key index is processed by the same VACUUM.
+-- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
+-- while every index entry for a removed heap tuple is deleted.
+SELECT indexrelname,
+       pages_deleted = 0 AS pages_deleted,
+       tuples_deleted = 500 AS tuples_deleted
+  FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
+  indexrelname  | pages_deleted | tuples_deleted 
+----------------+---------------+----------------
+ vacstat_t_pkey | t             | t
+(1 row)
+
+-- index page-deletion path: deleting a contiguous key range empties whole
+-- btree leaf pages, which VACUUM then deletes (pages_deleted > 0), and every
+-- removed index entry is counted (tuples_deleted).
+CREATE TABLE vacstat_idxdel (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_idxdel SELECT g, repeat('x', 20) FROM generate_series(1, 10000) g;
+VACUUM vacstat_idxdel;
+DELETE FROM vacstat_idxdel WHERE id <= 9000;
+VACUUM vacstat_idxdel;
+SELECT indexrelname,
+       pages_deleted > 0 AS pages_deleted,
+       tuples_deleted = 9000 AS tuples_deleted
+  FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_idxdel' ORDER BY indexrelname;
+    indexrelname     | pages_deleted | tuples_deleted 
+---------------------+---------------+----------------
+ vacstat_idxdel_pkey | t             | t
+(1 row)
+
+DROP TABLE vacstat_idxdel;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47f..3c73207a9f 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -91,6 +91,9 @@ test: select_parallel
 test: write_parallel
 test: vacuum_parallel
 
+# extended vacuum statistics views
+test: vacuum_stats
+
 # Run this alone, because concurrent DROP TABLE would make non-superuser
 # "ANALYZE;" fail with "relation with OID $n does not exist".
 test: maintain_every
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
new file mode 100644
index 0000000000..04696ed824
--- /dev/null
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -0,0 +1,68 @@
+--
+-- Extended vacuum statistics views (pg_stat_vacuum_tables, _indexes, _database)
+--
+SET track_vacuum_statistics = on;
+
+CREATE TABLE vacstat_t (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_t SELECT g, repeat('x', 20) FROM generate_series(1, 1000) g;
+DELETE FROM vacstat_t WHERE id % 2 = 0;
+VACUUM vacstat_t;
+
+-- core heap-page and dead-tuple metrics.  This VACUUM runs without concurrent
+-- activity: every deleted tuple is fully removable (recently_dead_tuples = 0),
+-- the surviving tuples are too fresh to be frozen (tuples_frozen = 0), and the
+-- interleaved deletions leave no trailing empty pages to truncate
+-- (pages_removed = 0).  The recently_dead_tuples non-zero path is covered by
+-- the vacuum-extending-in-repetable-read isolation test.
+SELECT pages_scanned > 0 AS pages_scanned,
+       pages_removed = 0 AS pages_removed,
+       tuples_deleted = 500 AS tuples_deleted,
+       tuples_frozen = 0 AS tuples_frozen,
+       recently_dead_tuples = 0 AS recently_dead_tuples
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
+-- pages_removed path: deleting every tuple lets VACUUM truncate the now-empty
+-- trailing heap pages, so pages_removed advances.
+CREATE TABLE vacstat_trunc (id int)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_trunc SELECT generate_series(1, 10000);
+DELETE FROM vacstat_trunc;
+VACUUM vacstat_trunc;
+SELECT pages_removed > 0 AS pages_removed,
+       tuples_deleted = 10000 AS tuples_deleted
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_trunc';
+DROP TABLE vacstat_trunc;
+
+-- tuples_frozen path: an aggressive VACUUM (FREEZE) freezes all live tuples,
+-- so tuples_frozen advances.
+CREATE TABLE vacstat_freeze (x int)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_freeze SELECT generate_series(1, 1000);
+VACUUM (FREEZE) vacstat_freeze;
+SELECT tuples_frozen > 0 AS tuples_frozen
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_freeze';
+DROP TABLE vacstat_freeze;
+
+-- per-index view: the primary key index is processed by the same VACUUM.
+-- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
+-- while every index entry for a removed heap tuple is deleted.
+SELECT indexrelname,
+       pages_deleted = 0 AS pages_deleted,
+       tuples_deleted = 500 AS tuples_deleted
+  FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
+
+-- index page-deletion path: deleting a contiguous key range empties whole
+-- btree leaf pages, which VACUUM then deletes (pages_deleted > 0), and every
+-- removed index entry is counted (tuples_deleted).
+CREATE TABLE vacstat_idxdel (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_idxdel SELECT g, repeat('x', 20) FROM generate_series(1, 10000) g;
+VACUUM vacstat_idxdel;
+DELETE FROM vacstat_idxdel WHERE id <= 9000;
+VACUUM vacstat_idxdel;
+SELECT indexrelname,
+       pages_deleted > 0 AS pages_deleted,
+       tuples_deleted = 9000 AS tuples_deleted
+  FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_idxdel' ORDER BY indexrelname;
+DROP TABLE vacstat_idxdel;
-- 
2.39.5 (Apple Git-154)



  [text/plain] 0003-Extended-vacuum-statistics-missed-dead-tuples-and-pa.patch (15.6K, 5-0003-Extended-vacuum-statistics-missed-dead-tuples-and-pa.patch)
  download | inline diff:
From 3abb75ddc121acec0970c7f469417f0ef637dfa2 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:41:24 +0300
Subject: [PATCH 3/8] Extended vacuum statistics: missed dead tuples and pages
 for tables

Expose the counters for dead tuples that VACUUM could not remove because it
failed to acquire a cleanup lock on their heap page, with documentation and
regression coverage:

  missed_dead_tuples  dead tuples skipped because their page could not be
                      cleanup-locked
  missed_dead_pages   heap pages that contained such skipped dead tuples
---
 doc/src/sgml/system-views.sgml                | 16 +++++++
 src/backend/access/heap/vacuumlazy.c          |  2 +
 src/backend/catalog/system_views.sql          |  4 +-
 src/backend/utils/activity/pgstat_vacuum.c    |  2 +
 src/backend/utils/adt/pgstatfuncs.c           |  4 +-
 src/include/catalog/pg_proc.dat               |  6 +--
 src/include/pgstat.h                          |  4 ++
 .../vacuum-extending-in-repetable-read.out    | 46 +++++++++++++++----
 .../vacuum-extending-in-repetable-read.spec   | 28 +++++++++--
 src/test/regress/expected/rules.out           |  6 ++-
 src/test/regress/expected/vacuum_stats.out    | 12 +++++
 src/test/regress/sql/vacuum_stats.sql         |  8 ++++
 12 files changed, 116 insertions(+), 22 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index bdcd8d9f47..b96c653929 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5876,6 +5876,22 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of dead tuples that are still visible to some transaction and therefore could not yet be removed.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>missed_dead_pages</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages containing dead tuples that the vacuum could not remove because it failed to acquire a cleanup lock.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>missed_dead_tuples</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of dead tuples that the vacuum could not remove because it failed to acquire a cleanup lock on their page.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 3c403e86f0..7abc83dbfd 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -631,6 +631,8 @@ accumulate_heap_vacuum_statistics(LVRelState *vacrel,
 	extVacStats->common.tuples_deleted = vacrel->tuples_deleted;
 	extVacStats->table.tuples_frozen = vacrel->tuples_frozen;
 	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
+	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
+	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
 }
 
 /*
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 01487f1665..6f697ab390 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1570,7 +1570,9 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.pages_removed AS pages_removed,
         S.tuples_deleted AS tuples_deleted,
         S.tuples_frozen AS tuples_frozen,
-        S.recently_dead_tuples AS recently_dead_tuples
+        S.recently_dead_tuples AS recently_dead_tuples,
+        S.missed_dead_pages AS missed_dead_pages,
+        S.missed_dead_tuples AS missed_dead_tuples
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 5a625132dd..8cc505ea86 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -54,6 +54,8 @@ pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
 		ACCUMULATE_SUBFIELD(table, pages_removed);
 		ACCUMULATE_SUBFIELD(table, tuples_frozen);
 		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
+		ACCUMULATE_SUBFIELD(table, missed_dead_pages);
+		ACCUMULATE_SUBFIELD(table, missed_dead_tuples);
 	}
 	else if (dst->type == PGSTAT_EXTVAC_INDEX)
 	{
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index d6927d945d..742f4974d5 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 6
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 8
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2405,6 +2405,8 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.tuples_deleted);
 	values[i++] = Int64GetDatum(extvacuum->table.tuples_frozen);
 	values[i++] = Int64GetDatum(extvacuum->table.recently_dead_tuples);
+	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_pages);
+	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_tuples);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 6bc3bd909b..6d683413a4 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index 404e7d0297..bdcf758441 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -218,6 +218,10 @@ typedef struct PgStat_VacuumRelationCounts
 										 * VM) */
 			int64		pages_removed;	/* heap pages removed by vacuum
 										 * "truncation" */
+			int64		missed_dead_pages;	/* pages with missed dead tuples */
+			int64		missed_dead_tuples; /* tuples not pruned by vacuum due
+											 * to failure to get a cleanup
+											 * lock */
 		}			table;
 		struct
 		{
diff --git a/src/test/isolation/expected/vacuum-extending-in-repetable-read.out b/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
index a9d977c520..3eb038b9ad 100644
--- a/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
+++ b/src/test/isolation/expected/vacuum-extending-in-repetable-read.out
@@ -1,6 +1,6 @@
-Parsed test spec with 2 sessions
+Parsed test spec with 3 sessions
 
-starting permutation: s1_begin_repeatable_read s2_delete s2_vacuum s2_print_vacuum_stats_table s1_commit s2_vacuum s2_print_vacuum_stats_table
+starting permutation: s1_begin_repeatable_read s2_delete s2_vacuum s2_print_vacuum_stats_table s1_commit pinholder_cursor s2_vacuum s2_print_vacuum_stats_table pinholder_commit s2_vacuum s2_print_vacuum_stats_table
 step s1_begin_repeatable_read: 
   BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
   SELECT count(*) FROM test_vacuum_stat_isolation;
@@ -14,25 +14,51 @@ step s2_delete: DELETE FROM test_vacuum_stat_isolation WHERE id <= 10;
 step s2_vacuum: VACUUM test_vacuum_stat_isolation;
 step s2_print_vacuum_stats_table: 
     SELECT
-    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples,
+    vt.missed_dead_tuples, vt.missed_dead_pages
     FROM pg_stat_vacuum_tables vt, pg_class c
     WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
 
-relname                   |tuples_deleted|recently_dead_tuples
---------------------------+--------------+--------------------
-test_vacuum_stat_isolation|             0|                  10
+relname                   |tuples_deleted|recently_dead_tuples|missed_dead_tuples|missed_dead_pages
+--------------------------+--------------+--------------------+------------------+-----------------
+test_vacuum_stat_isolation|             0|                  10|                 0|                0
 (1 row)
 
 step s1_commit: COMMIT;
+step pinholder_cursor: 
+  BEGIN;
+  DECLARE c CURSOR FOR SELECT 1 AS dummy FROM test_vacuum_stat_isolation;
+  FETCH NEXT FROM c;
+
+dummy
+-----
+    1
+(1 row)
+
+step s2_vacuum: VACUUM test_vacuum_stat_isolation;
+step s2_print_vacuum_stats_table: 
+    SELECT
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples,
+    vt.missed_dead_tuples, vt.missed_dead_pages
+    FROM pg_stat_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+
+relname                   |tuples_deleted|recently_dead_tuples|missed_dead_tuples|missed_dead_pages
+--------------------------+--------------+--------------------+------------------+-----------------
+test_vacuum_stat_isolation|             0|                  10|                10|                1
+(1 row)
+
+step pinholder_commit: COMMIT;
 step s2_vacuum: VACUUM test_vacuum_stat_isolation;
 step s2_print_vacuum_stats_table: 
     SELECT
-    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples,
+    vt.missed_dead_tuples, vt.missed_dead_pages
     FROM pg_stat_vacuum_tables vt, pg_class c
     WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
 
-relname                   |tuples_deleted|recently_dead_tuples
---------------------------+--------------+--------------------
-test_vacuum_stat_isolation|            10|                  10
+relname                   |tuples_deleted|recently_dead_tuples|missed_dead_tuples|missed_dead_pages
+--------------------------+--------------+--------------------+------------------+-----------------
+test_vacuum_stat_isolation|            10|                  10|                10|                1
 (1 row)
 
diff --git a/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec b/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
index 50b79715f3..334846193d 100644
--- a/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
+++ b/src/test/isolation/specs/vacuum-extending-in-repetable-read.spec
@@ -1,9 +1,11 @@
-# Test for recently_dead_tuples in pg_stat_vacuum_tables.
+# Test for recently_dead_tuples and missed_dead_tuples/missed_dead_pages in
+# pg_stat_vacuum_tables.
 #
 # A tuple deleted while an older snapshot can still see it is counted as
-# recently_dead_tuples, because VACUUM is not allowed to remove it yet; once
-# the older snapshot is gone, the next VACUUM removes it and counts it in
-# tuples_deleted instead.
+# recently_dead_tuples, because VACUUM is not allowed to remove it yet.  Once
+# the tuple becomes removable but VACUUM cannot acquire a cleanup lock on its
+# heap page (because another session pins the page), it is counted instead as
+# missed_dead_tuples, and its heap page as missed_dead_pages.
 
 setup
 {
@@ -30,6 +32,17 @@ step s1_begin_repeatable_read
 }
 step s1_commit			{ COMMIT; }
 
+# Holds a pin on the table's single heap page, so VACUUM cannot get a
+# cleanup lock on it:
+session pinholder
+step pinholder_cursor
+{
+  BEGIN;
+  DECLARE c CURSOR FOR SELECT 1 AS dummy FROM test_vacuum_stat_isolation;
+  FETCH NEXT FROM c;
+}
+step pinholder_commit	{ COMMIT; }
+
 # Performs the DML, the vacuums and prints the collected statistics:
 session s2
 setup		{ SET track_vacuum_statistics TO 'on'; }
@@ -38,7 +51,8 @@ step s2_vacuum			{ VACUUM test_vacuum_stat_isolation; }
 step s2_print_vacuum_stats_table
 {
     SELECT
-    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples
+    vt.relname, vt.tuples_deleted, vt.recently_dead_tuples,
+    vt.missed_dead_tuples, vt.missed_dead_pages
     FROM pg_stat_vacuum_tables vt, pg_class c
     WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
 }
@@ -49,5 +63,9 @@ permutation
     s2_vacuum
     s2_print_vacuum_stats_table
     s1_commit
+    pinholder_cursor
+    s2_vacuum
+    s2_print_vacuum_stats_table
+    pinholder_commit
     s2_vacuum
     s2_print_vacuum_stats_table
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 40c6aca96e..a2b0472a2d 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2441,10 +2441,12 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.pages_removed,
     s.tuples_deleted,
     s.tuples_frozen,
-    s.recently_dead_tuples
+    s.recently_dead_tuples,
+    s.missed_dead_pages,
+    s.missed_dead_tuples
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index c3079e3379..f342f71c57 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -54,6 +54,18 @@ SELECT tuples_frozen > 0 AS tuples_frozen
 (1 row)
 
 DROP TABLE vacstat_freeze;
+-- dead tuples/pages that could not be removed because the page was pinned by
+-- another backend (cleanup lock not acquired); none here, since this VACUUM
+-- runs without concurrent activity.  The non-zero path is covered by the
+-- vacuum-extending-in-repetable-read isolation test.
+SELECT missed_dead_pages = 0 AS missed_dead_pages,
+       missed_dead_tuples = 0 AS missed_dead_tuples
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ missed_dead_pages | missed_dead_tuples 
+-------------------+--------------------
+ t                 | t
+(1 row)
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index 04696ed824..b6f0f55af0 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -44,6 +44,14 @@ SELECT tuples_frozen > 0 AS tuples_frozen
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_freeze';
 DROP TABLE vacstat_freeze;
 
+-- dead tuples/pages that could not be removed because the page was pinned by
+-- another backend (cleanup lock not acquired); none here, since this VACUUM
+-- runs without concurrent activity.  The non-zero path is covered by the
+-- vacuum-extending-in-repetable-read isolation test.
+SELECT missed_dead_pages = 0 AS missed_dead_pages,
+       missed_dead_tuples = 0 AS missed_dead_tuples
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
-- 
2.39.5 (Apple Git-154)



  [text/plain] 0004-Extended-vacuum-statistics-visibility-map-page-trans.patch (12.8K, 6-0004-Extended-vacuum-statistics-visibility-map-page-trans.patch)
  download | inline diff:
From 7acaea3543f0276bce73ac5fd9208744035ea2a5 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:48:17 +0300
Subject: [PATCH 4/8] Extended vacuum statistics: visibility-map page
 transitions for tables

Expose the counters that track how the vacuum updated the visibility map of
the table, with documentation and regression coverage:

  vm_new_frozen_pages          pages newly marked all-frozen in the VM
  vm_new_visible_pages         pages newly marked all-visible in the VM
  vm_new_visible_frozen_pages  pages newly marked all-visible and all-frozen
---
 doc/src/sgml/system-views.sgml             | 24 ++++++++++++++++
 src/backend/access/heap/vacuumlazy.c       |  3 ++
 src/backend/catalog/system_views.sql       |  5 +++-
 src/backend/utils/activity/pgstat_vacuum.c |  3 ++
 src/backend/utils/adt/pgstatfuncs.c        |  5 +++-
 src/include/catalog/pg_proc.dat            |  6 ++--
 src/include/pgstat.h                       |  7 +++++
 src/test/regress/expected/rules.out        |  7 +++--
 src/test/regress/expected/vacuum_stats.out | 33 ++++++++++++++++++++++
 src/test/regress/sql/vacuum_stats.sql      | 25 ++++++++++++++++
 10 files changed, 111 insertions(+), 7 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index b96c653929..704d9e85eb 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5892,6 +5892,30 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of dead tuples that the vacuum could not remove because it failed to acquire a cleanup lock on their page.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_frozen_pages</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages newly marked all-frozen in the visibility map by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_visible_pages</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages newly marked all-visible in the visibility map by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_visible_frozen_pages</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of heap pages newly marked both all-visible and all-frozen in the visibility map by the vacuum.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 7abc83dbfd..91b6d9a9d9 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -633,6 +633,9 @@ accumulate_heap_vacuum_statistics(LVRelState *vacrel,
 	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
 	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
 	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
+	extVacStats->table.vm_new_frozen_pages = vacrel->new_all_frozen_pages;
+	extVacStats->table.vm_new_visible_pages = vacrel->new_all_visible_pages;
+	extVacStats->table.vm_new_visible_frozen_pages = vacrel->new_all_visible_all_frozen_pages;
 }
 
 /*
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 6f697ab390..21fa841f5a 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1572,7 +1572,10 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.tuples_frozen AS tuples_frozen,
         S.recently_dead_tuples AS recently_dead_tuples,
         S.missed_dead_pages AS missed_dead_pages,
-        S.missed_dead_tuples AS missed_dead_tuples
+        S.missed_dead_tuples AS missed_dead_tuples,
+        S.vm_new_frozen_pages AS vm_new_frozen_pages,
+        S.vm_new_visible_pages AS vm_new_visible_pages,
+        S.vm_new_visible_frozen_pages AS vm_new_visible_frozen_pages
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 8cc505ea86..4f6cbfd540 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -56,6 +56,9 @@ pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
 		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
 		ACCUMULATE_SUBFIELD(table, missed_dead_pages);
 		ACCUMULATE_SUBFIELD(table, missed_dead_tuples);
+		ACCUMULATE_SUBFIELD(table, vm_new_frozen_pages);
+		ACCUMULATE_SUBFIELD(table, vm_new_visible_pages);
+		ACCUMULATE_SUBFIELD(table, vm_new_visible_frozen_pages);
 	}
 	else if (dst->type == PGSTAT_EXTVAC_INDEX)
 	{
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 742f4974d5..3acf0a7391 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 8
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 11
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2407,6 +2407,9 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->table.recently_dead_tuples);
 	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_pages);
 	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_tuples);
+	values[i++] = Int64GetDatum(extvacuum->table.vm_new_frozen_pages);
+	values[i++] = Int64GetDatum(extvacuum->table.vm_new_visible_pages);
+	values[i++] = Int64GetDatum(extvacuum->table.vm_new_visible_frozen_pages);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 6d683413a4..7d87b03239 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index bdcf758441..f2bdef1463 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -222,6 +222,13 @@ typedef struct PgStat_VacuumRelationCounts
 			int64		missed_dead_tuples; /* tuples not pruned by vacuum due
 											 * to failure to get a cleanup
 											 * lock */
+			int64		vm_new_frozen_pages;	/* pages marked in VM as
+												 * frozen */
+			int64		vm_new_visible_pages;	/* pages marked in VM as
+												 * all-visible */
+			int64		vm_new_visible_frozen_pages;	/* pages marked in VM as
+														 * all-visible and
+														 * frozen */
 		}			table;
 		struct
 		{
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index a2b0472a2d..c30ff6c72f 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2443,10 +2443,13 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.tuples_frozen,
     s.recently_dead_tuples,
     s.missed_dead_pages,
-    s.missed_dead_tuples
+    s.missed_dead_tuples,
+    s.vm_new_frozen_pages,
+    s.vm_new_visible_pages,
+    s.vm_new_visible_frozen_pages
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index f342f71c57..0b3354cfff 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -66,6 +66,39 @@ SELECT missed_dead_pages = 0 AS missed_dead_pages,
  t                 | t
 (1 row)
 
+-- visibility-map page transitions.  Removing the interleaved dead tuples lets
+-- VACUUM mark every heap page all-visible (vm_new_visible_pages > 0).  Whether
+-- VACUUM also freezes those pages (vm_new_frozen_pages /
+-- vm_new_visible_frozen_pages) depends on opportunistic freezing, which is not
+-- deterministic here, so those are only checked for being non-negative; the
+-- positive freeze path is covered by the dedicated VACUUM (FREEZE) scenario
+-- below.
+SELECT vm_new_frozen_pages >= 0 AS vm_new_frozen_pages,
+       vm_new_visible_pages > 0 AS vm_new_visible_pages,
+       vm_new_visible_frozen_pages >= 0 AS vm_new_visible_frozen_pages
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ vm_new_frozen_pages | vm_new_visible_pages | vm_new_visible_frozen_pages 
+---------------------+----------------------+-----------------------------
+ t                   | t                    | t
+(1 row)
+
+-- freeze path: a dedicated VACUUM (FREEZE) marks freshly-loaded heap pages
+-- all-visible and all-frozen in one pass, so vm_new_visible_frozen_pages
+-- advances.  This restores the coverage of the former
+-- 053_vacuum_extending_freeze TAP test.
+CREATE TABLE vacstat_frz (x int)
+  WITH (autovacuum_enabled = off, fillfactor = 10);
+INSERT INTO vacstat_frz SELECT g FROM generate_series(1, 1000) g;
+VACUUM (FREEZE) vacstat_frz;
+SELECT vm_new_visible_pages > 0 AS vm_new_visible_pages,
+       vm_new_visible_frozen_pages > 0 AS vm_new_visible_frozen_pages
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_frz';
+ vm_new_visible_pages | vm_new_visible_frozen_pages 
+----------------------+-----------------------------
+ t                    | t
+(1 row)
+
+DROP TABLE vacstat_frz;
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index b6f0f55af0..db80ecf6a1 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -52,6 +52,31 @@ SELECT missed_dead_pages = 0 AS missed_dead_pages,
        missed_dead_tuples = 0 AS missed_dead_tuples
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
 
+-- visibility-map page transitions.  Removing the interleaved dead tuples lets
+-- VACUUM mark every heap page all-visible (vm_new_visible_pages > 0).  Whether
+-- VACUUM also freezes those pages (vm_new_frozen_pages /
+-- vm_new_visible_frozen_pages) depends on opportunistic freezing, which is not
+-- deterministic here, so those are only checked for being non-negative; the
+-- positive freeze path is covered by the dedicated VACUUM (FREEZE) scenario
+-- below.
+SELECT vm_new_frozen_pages >= 0 AS vm_new_frozen_pages,
+       vm_new_visible_pages > 0 AS vm_new_visible_pages,
+       vm_new_visible_frozen_pages >= 0 AS vm_new_visible_frozen_pages
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
+-- freeze path: a dedicated VACUUM (FREEZE) marks freshly-loaded heap pages
+-- all-visible and all-frozen in one pass, so vm_new_visible_frozen_pages
+-- advances.  This restores the coverage of the former
+-- 053_vacuum_extending_freeze TAP test.
+CREATE TABLE vacstat_frz (x int)
+  WITH (autovacuum_enabled = off, fillfactor = 10);
+INSERT INTO vacstat_frz SELECT g FROM generate_series(1, 1000) g;
+VACUUM (FREEZE) vacstat_frz;
+SELECT vm_new_visible_pages > 0 AS vm_new_visible_pages,
+       vm_new_visible_frozen_pages > 0 AS vm_new_visible_frozen_pages
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_frz';
+DROP TABLE vacstat_frz;
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
-- 
2.39.5 (Apple Git-154)



  [text/plain] 0005-Extended-vacuum-statistics-buffer-WAL-timing-machine.patch (66.4K, 7-0005-Extended-vacuum-statistics-buffer-WAL-timing-machine.patch)
  download | inline diff:
From 21ebf04efe2f19794161d460302d3c8491dd6732 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:51:25 +0300
Subject: [PATCH 5/8] Extended vacuum statistics: buffer/WAL/timing machinery
 and total buffer counters
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Introduce the resource-usage sampling machinery for extended vacuum
statistics, the database-wide aggregate, and the first counters that depend
on the sampling.

This commit introduces the resource-usage sampling that the buffer, WAL and
timing metrics rely on.  Around the processing of each relation, vacuum
snapshots WAL, buffer and timing usage and records the difference, so every
sampled metric reflects only that vacuum's work.  Index processing is sampled
on its own — including in parallel workers — and its usage is kept out of the
parent table's figures, so heap and index work are accounted separately.

The PGSTAT_KIND_VACUUM_DB stats kind is added here as well: it aggregates the
common (buffer/WAL/timing) counters of every relation vacuumed in the
database.  pgstat_accumulate_common() -- which sums those counters -- is
introduced together with it, since it is only meaningful once the sampling
exists.

This commit also creates the two remaining views, pg_stat_vacuum_indexes and
pg_stat_vacuum_database, alongside pg_stat_vacuum_tables.  They are introduced
here because they expose the common (buffer/WAL/timing) counters produced by
the sampling machinery; like the table view, they then grow column by column
in the following commits as each metric category is added.  The per-index view
also exposes the index-specific pages_deleted and tuples_deleted counters from
the start.

This commit exposes the shared-buffer access counters in all three views,
with documentation and regression coverage:

  total_blks_read     shared buffer blocks missed (read from disk)
  total_blks_hit      shared buffer blocks found in the buffer cache (hits)
  total_blks_dirtied  shared buffer blocks dirtied by this vacuum
  total_blks_written  shared buffer blocks written out

These counters are common to tables, indexes and the database aggregate
(where the first two are named db_blks_read/db_blks_hit); total_blks_dirtied
counts only the pages dirtied by this vacuum.
---
 doc/src/sgml/system-views.sgml               | 245 +++++++++++
 src/backend/access/heap/vacuumlazy.c         | 426 +++++++++++++++----
 src/backend/catalog/system_views.sql         |  28 +-
 src/backend/commands/dbcommands.c            |   1 +
 src/backend/commands/vacuum.c                |   4 +
 src/backend/commands/vacuumparallel.c        |   1 +
 src/backend/utils/activity/pgstat.c          |  15 +
 src/backend/utils/activity/pgstat_database.c |   9 +
 src/backend/utils/activity/pgstat_vacuum.c   | 106 ++++-
 src/backend/utils/adt/pgstatfuncs.c          |  53 ++-
 src/include/catalog/pg_proc.dat              |  21 +-
 src/include/commands/vacuum.h                |  29 ++
 src/include/pgstat.h                         |  16 +
 src/include/utils/pgstat_internal.h          |   7 +
 src/include/utils/pgstat_kind.h              |   3 +-
 src/test/regress/expected/rules.out          |  25 +-
 src/test/regress/expected/vacuum_stats.out   |  45 +-
 src/test/regress/sql/vacuum_stats.sql        |  31 +-
 18 files changed, 945 insertions(+), 120 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 704d9e85eb..088b532e5c 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5916,6 +5916,251 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of heap pages newly marked both all-visible and all-frozen in the visibility map by the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks missed (read from disk) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks found in the buffer cache (hits) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_dirtied</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks dirtied by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_written</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks written out by the vacuum.
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
+ <sect1 id="view-pg-stat-vacuum-indexes">
+  <title><structname>pg_stat_vacuum_indexes</structname></title>
+
+  <indexterm zone="view-pg-stat-vacuum-indexes">
+   <primary>pg_stat_vacuum_indexes</primary>
+  </indexterm>
+
+  <para>
+   The <structname>pg_stat_vacuum_indexes</structname> view will contain one row
+   for each index in the current database, showing extended statistics about
+   the activity of the most recent <command>VACUUM</command> on that index.
+   These statistics are accumulated only while
+   <varname>track_vacuum_statistics</varname> is enabled.
+  </para>
+
+  <table id="view-pg-stat-vacuum-indexes-cols">
+   <title><structname>pg_stat_vacuum_indexes</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the table the index is on.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>indexrelid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>schemaname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the schema that the table is in.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the table.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>indexrelname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of index pages deleted (made reusable) by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_deleted</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of index entries removed by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks read while vacuuming the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer block hits while vacuuming the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_dirtied</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks dirtied while vacuuming the index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_written</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks written out while vacuuming the index.
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
+ <sect1 id="view-pg-stat-vacuum-database">
+  <title><structname>pg_stat_vacuum_database</structname></title>
+
+  <indexterm zone="view-pg-stat-vacuum-database">
+   <primary>pg_stat_vacuum_database</primary>
+  </indexterm>
+
+  <para>
+   The <structname>pg_stat_vacuum_database</structname> view will contain one row
+   for each database, showing extended statistics aggregated over all
+   <command>VACUUM</command> activity in that database.  These statistics are
+   accumulated only while <varname>track_vacuum_statistics</varname> is enabled.
+  </para>
+
+  <table id="view-pg-stat-vacuum-database-cols">
+   <title><structname>pg_stat_vacuum_database</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>dboid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of the database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>dbname</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>errors</structfield> <type>integer</type>
+      </para>
+      <para>
+       Number of vacuum operations in this database that failed with an error.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>db_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks read by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>db_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer block hits by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_dirtied</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks dirtied by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_written</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of shared buffer blocks written out by vacuum operations in this database.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 91b6d9a9d9..9c524909fe 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -411,6 +411,25 @@ typedef struct LVRelState
 	 * been permanently disabled.
 	 */
 	BlockNumber eager_scan_remaining_fails;
+
+	int32		wraparound_failsafe_count;	/* number of emergency vacuums to
+											 * prevent anti-wraparound
+											 * shutdown */
+
+	PgStat_VacuumRelationCounts extVacReportIdx;
+
+	/*
+	 * Per-index accumulated extended vacuum statistics.  VACUUM may process an
+	 * index several times (a bulkdelete pass per index scan plus a final
+	 * cleanup pass); we sum those passes here and report them to the
+	 * cumulative stats system exactly once per index at the end of the vacuum.
+	 * Only used by the non-parallel path -- parallel index vacuuming keeps the
+	 * equivalent running totals in the DSM and reports from
+	 * parallel_vacuum_end().  Sized vacrel->nindexes; NULL if there are no
+	 * indexes.
+	 */
+	PgStat_VacuumRelationCounts *extVacIdxReports;
+	bool	   *extVacIdxTouched;	/* was the matching index processed? */
 } LVRelState;
 
 
@@ -422,7 +441,6 @@ typedef struct LVSavedErrInfo
 	VacErrPhase phase;
 } LVSavedErrInfo;
 
-
 /* non-export function prototypes */
 static void lazy_scan_heap(LVRelState *vacrel);
 static void heap_vacuum_eager_scan_setup(LVRelState *vacrel,
@@ -452,12 +470,12 @@ static void lazy_cleanup_all_indexes(LVRelState *vacrel);
 static IndexBulkDeleteResult *lazy_vacuum_one_index(Relation indrel,
 													IndexBulkDeleteResult *istat,
 													double reltuples,
-													LVRelState *vacrel);
+													LVRelState *vacrel, int idx);
 static IndexBulkDeleteResult *lazy_cleanup_one_index(Relation indrel,
 													 IndexBulkDeleteResult *istat,
 													 double reltuples,
 													 bool estimated_count,
-													 LVRelState *vacrel);
+													 LVRelState *vacrel, int idx);
 static bool should_attempt_truncation(LVRelState *vacrel);
 static void lazy_truncate_heap(LVRelState *vacrel);
 static BlockNumber count_nondeletable_pages(LVRelState *vacrel,
@@ -484,9 +502,223 @@ static void update_vacuum_error_info(LVRelState *vacrel,
 									 OffsetNumber offnum);
 static void restore_vacuum_error_info(LVRelState *vacrel,
 									  const LVSavedErrInfo *saved_vacrel);
-static void accumulate_heap_vacuum_statistics(LVRelState *vacrel,
-											  PgStat_VacuumRelationCounts *extVacStats);
 
+/* ----------
+ * extvac_stats_start() -
+ *
+ * Save cut-off values of extended vacuum counters before start of a relation
+ * processing.
+ * ----------
+ */
+static void
+extvac_stats_start(Relation rel, LVExtStatCounters * counters)
+{
+	TimestampTz starttime;
+
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	memset(counters, 0, sizeof(LVExtStatCounters));
+
+	starttime = GetCurrentTimestamp();
+
+	counters->starttime = starttime;
+	counters->walusage = pgWalUsage;
+	counters->bufusage = pgBufferUsage;
+	counters->VacuumDelayTime = VacuumDelayTime;
+	counters->blocks_fetched = 0;
+	counters->blocks_hit = 0;
+
+	if (!rel->pgstat_info || !pgstat_track_counts)
+
+		/*
+		 * if something goes wrong or user doesn't want to track a database
+		 * activity - just suppress it.
+		 */
+		return;
+
+	counters->blocks_fetched = rel->pgstat_info->counts.blocks_fetched;
+	counters->blocks_hit = rel->pgstat_info->counts.blocks_hit;
+}
+
+/* ----------
+ * extvac_stats_end() -
+ *
+ *	Called to finish an extended vacuum statistic gathering and form a report.
+ * ----------
+ */
+static void
+extvac_stats_end(Relation rel, LVExtStatCounters * counters,
+				 PgStat_CommonCounts * report)
+{
+	BufferUsage bufusage;
+
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	memset(report, 0, sizeof(PgStat_CommonCounts));
+
+	/* Calculate diffs of global stat parameters on buffer usage. */
+	memset(&bufusage, 0, sizeof(BufferUsage));
+	BufferUsageAccumDiff(&bufusage, &pgBufferUsage, &counters->bufusage);
+
+	/*
+	 * Fill additional statistics on a vacuum processing operation.
+	 */
+	report->total_blks_read += bufusage.local_blks_read + bufusage.shared_blks_read;
+	report->total_blks_hit += bufusage.local_blks_hit + bufusage.shared_blks_hit;
+	report->total_blks_dirtied += bufusage.local_blks_dirtied + bufusage.shared_blks_dirtied;
+	report->total_blks_written += bufusage.shared_blks_written;
+}
+
+void
+extvac_stats_start_idx(Relation rel, IndexBulkDeleteResult *stats,
+					   LVExtStatCountersIdx * counters)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	/* Set initial values for common heap and index statistics */
+	extvac_stats_start(rel, &counters->common);
+	counters->pages_deleted = counters->tuples_removed = 0;
+
+	if (stats != NULL)
+	{
+		/*
+		 * XXX: Why do we need this code here? If it is needed, I feel lack of
+		 * comments, describing the reason.
+		 */
+		counters->tuples_removed = stats->tuples_removed;
+		counters->pages_deleted = stats->pages_deleted;
+	}
+}
+
+void
+extvac_stats_end_idx(Relation rel, IndexBulkDeleteResult *stats,
+					 LVExtStatCountersIdx * counters, PgStat_VacuumRelationCounts * report)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	memset(report, 0, sizeof(PgStat_VacuumRelationCounts));
+
+	extvac_stats_end(rel, &counters->common, &report->common);
+
+	report->type = PGSTAT_EXTVAC_INDEX;
+
+	if (stats != NULL)
+	{
+		/*
+		 * if something goes wrong or an user doesn't want to track a database
+		 * activity - just suppress it.
+		 */
+
+		/* Fill index-specific extended stats fields */
+		report->common.tuples_deleted =
+			stats->tuples_removed - counters->tuples_removed;
+		report->index.pages_deleted =
+			stats->pages_deleted - counters->pages_deleted;
+	}
+}
+
+/* Accumulate vacuum statistics for heap.
+ *
+  * Because of complexity of vacuum processing: it switch procesing between
+  * the heap relation to index relations and visa versa, we need to store
+  * gathered statistics information for heap relations several times before
+  * the vacuum starts processing the indexes again.
+  *
+  * It is necessary to gather correct statistics information for heap and indexes
+  * otherwice the index statistics information would be added to his parent heap
+  * statistics information and it would be difficult to analyze it later.
+  *
+  * We can't subtract union vacuum statistics information for index from the heap relations
+  * because of total and delay time time statistics collecting during parallel vacuum
+  * procudure.
+*/
+static void
+accumulate_heap_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCounts * extVacStats)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	/* Fill heap-specific extended stats fields */
+	extVacStats->type = PGSTAT_EXTVAC_TABLE;
+	extVacStats->table.pages_scanned = vacrel->scanned_pages;
+	extVacStats->table.pages_removed = vacrel->removed_pages;
+	extVacStats->table.vm_new_frozen_pages = vacrel->new_all_frozen_pages;
+	extVacStats->table.vm_new_visible_pages = vacrel->new_all_visible_pages;
+	extVacStats->table.vm_new_visible_frozen_pages = vacrel->new_all_visible_all_frozen_pages;
+	extVacStats->common.tuples_deleted = vacrel->tuples_deleted;
+	extVacStats->table.tuples_frozen = vacrel->tuples_frozen;
+	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
+	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
+	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
+
+	extVacStats->common.total_blks_dirtied -= vacrel->extVacReportIdx.common.total_blks_dirtied;
+	extVacStats->common.total_blks_hit -= vacrel->extVacReportIdx.common.total_blks_hit;
+	extVacStats->common.total_blks_read -= vacrel->extVacReportIdx.common.total_blks_read;
+	extVacStats->common.total_blks_written -= vacrel->extVacReportIdx.common.total_blks_written;
+}
+
+static void
+accumulate_idxs_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCounts * extVacIdxStats)
+{
+	/* Fill heap-specific extended stats fields */
+	vacrel->extVacReportIdx.common.total_blks_dirtied += extVacIdxStats->common.total_blks_dirtied;
+	vacrel->extVacReportIdx.common.total_blks_hit += extVacIdxStats->common.total_blks_hit;
+	vacrel->extVacReportIdx.common.total_blks_read += extVacIdxStats->common.total_blks_read;
+	vacrel->extVacReportIdx.common.total_blks_written += extVacIdxStats->common.total_blks_written;
+}
+
+/*
+ * Accumulate one index extended-vacuum report into a per-index running total.
+ *
+ * VACUUM may touch an index more than once: a bulkdelete pass for every index
+ * scan, plus a final cleanup pass.  Rather than reporting to the cumulative
+ * stats system on every pass, callers sum the passes here and report the
+ * totals exactly once per index at the end of the vacuum.  Shared between the
+ * non-parallel path (accumulating in vacrel) and the parallel path
+ * (accumulating in the DSM), hence not static.
+ */
+void
+extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
+							 const PgStat_VacuumRelationCounts * src)
+{
+	dst->type = PGSTAT_EXTVAC_INDEX;
+
+	dst->common.total_blks_read += src->common.total_blks_read;
+	dst->common.total_blks_hit += src->common.total_blks_hit;
+	dst->common.total_blks_dirtied += src->common.total_blks_dirtied;
+	dst->common.total_blks_written += src->common.total_blks_written;
+	dst->common.tuples_deleted += src->common.tuples_deleted;
+
+	dst->index.pages_deleted += src->index.pages_deleted;
+}
+
+/*
+ * Report the accumulated per-index extended vacuum statistics, one report per
+ * index.  Used by the non-parallel path only; the parallel path reports its
+ * DSM-resident totals from parallel_vacuum_end().
+ */
+static void
+report_index_vacuum_extstats(LVRelState *vacrel)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	for (int idx = 0; idx < vacrel->nindexes; idx++)
+	{
+		Relation	indrel = vacrel->indrels[idx];
+
+		if (!vacrel->extVacIdxTouched[idx])
+			continue;
+
+		pgstat_report_vacuum_extstats(RelationGetRelid(indrel),
+									  indrel->rd_rel->relisshared,
+									  &vacrel->extVacIdxReports[idx]);
+	}
+}
 
 
 /*
@@ -612,69 +844,6 @@ heap_vacuum_eager_scan_setup(LVRelState *vacrel, const VacuumParams *params)
 		first_region_ratio;
 }
 
-/*
- * Fill the extended vacuum statistics report for a heap relation with the
- * counters that are derived directly from the LVRelState.  Buffer, WAL and
- * timing counters require sampling resource usage around the vacuum and are
- * gathered separately; they are not touched here.
- */
-static void
-accumulate_heap_vacuum_statistics(LVRelState *vacrel,
-								  PgStat_VacuumRelationCounts *extVacStats)
-{
-	if (!pgstat_track_vacuum_statistics)
-		return;
-
-	extVacStats->type = PGSTAT_EXTVAC_TABLE;
-	extVacStats->table.pages_scanned = vacrel->scanned_pages;
-	extVacStats->table.pages_removed = vacrel->removed_pages;
-	extVacStats->common.tuples_deleted = vacrel->tuples_deleted;
-	extVacStats->table.tuples_frozen = vacrel->tuples_frozen;
-	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
-	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
-	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
-	extVacStats->table.vm_new_frozen_pages = vacrel->new_all_frozen_pages;
-	extVacStats->table.vm_new_visible_pages = vacrel->new_all_visible_pages;
-	extVacStats->table.vm_new_visible_frozen_pages = vacrel->new_all_visible_all_frozen_pages;
-}
-
-/*
- * Report the per-index extended vacuum statistics, one report per index.
- *
- * The per-index counters (pages_deleted and the number of removed index
- * entries) are derived directly from each index's final IndexBulkDeleteResult,
- * which already holds the totals accumulated across all bulkdelete and cleanup
- * passes -- so no per-pass sampling is needed here.  Used by the non-parallel
- * path only; the parallel path reports its DSM-resident results from
- * parallel_vacuum_end().
- */
-static void
-report_index_vacuum_extstats(LVRelState *vacrel)
-{
-	if (!pgstat_track_vacuum_statistics)
-		return;
-
-	for (int idx = 0; idx < vacrel->nindexes; idx++)
-	{
-		Relation	indrel = vacrel->indrels[idx];
-		IndexBulkDeleteResult *istat = vacrel->indstats[idx];
-		PgStat_VacuumRelationCounts report;
-
-		/* Skip indexes that this vacuum did not process */
-		if (istat == NULL)
-			continue;
-
-		memset(&report, 0, sizeof(report));
-		report.type = PGSTAT_EXTVAC_INDEX;
-		report.common.tuples_deleted = istat->tuples_removed;
-		report.index.pages_deleted = istat->pages_deleted;
-
-		pgstat_report_vacuum_extstats(RelationGetRelid(indrel),
-									  indrel->rd_rel->relisshared,
-									  &report);
-	}
-}
-
 /*
  *	heap_vacuum_rel() -- perform VACUUM for one heap relation
  *
@@ -701,7 +870,6 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 				new_rel_allvisible,
 				new_rel_allfrozen;
 	PGRUsage	ru0;
-	TimestampTz starttime = 0;
 	PgStat_Counter startreadtime = 0,
 				startwritetime = 0;
 	WalUsage	startwalusage = pgWalUsage;
@@ -709,9 +877,10 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	ErrorContextCallback errcallback;
 	char	  **indnames = NULL;
 	Size		dead_items_max_bytes = 0;
+	LVExtStatCounters extVacCounters;
 	PgStat_VacuumRelationCounts extVacReport;
 
-	/* Initialize the extended vacuum statistics report */
+	/* Initialize vacuum statistics */
 	memset(&extVacReport, 0, sizeof(PgStat_VacuumRelationCounts));
 
 	verbose = (params->options & VACOPT_VERBOSE) != 0;
@@ -727,8 +896,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 		}
 	}
 
-	/* Used for instrumentation and stats report */
-	starttime = GetCurrentTimestamp();
+	extvac_stats_start(rel, &extVacCounters);
 
 	pgstat_progress_start_command(PROGRESS_COMMAND_VACUUM,
 								  RelationGetRelid(rel));
@@ -756,8 +924,8 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel = palloc0_object(LVRelState);
 	vacrel->dbname = get_database_name(MyDatabaseId);
 	vacrel->relnamespace = get_namespace_name(RelationGetNamespace(rel));
-	vacrel->reloid = RelationGetRelid(rel);
 	vacrel->relname = pstrdup(RelationGetRelationName(rel));
+	vacrel->reloid = RelationGetRelid(rel);
 	vacrel->indname = NULL;
 	vacrel->phase = VACUUM_ERRCB_PHASE_UNKNOWN;
 	vacrel->verbose = verbose;
@@ -766,12 +934,26 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	errcallback.previous = error_context_stack;
 	error_context_stack = &errcallback;
 
+	memset(&vacrel->extVacReportIdx, 0, sizeof(PgStat_VacuumRelationCounts));
+	memset(&extVacReport.common, 0, sizeof(PgStat_CommonCounts));
+
 	/* Set up high level stuff about rel and its indexes */
 	vacrel->rel = rel;
 	vac_open_indexes(vacrel->rel, RowExclusiveLock, &vacrel->nindexes,
 					 &vacrel->indrels);
 	vacrel->bstrategy = bstrategy;
 
+	/*
+	 * Allocate per-index accumulators for extended vacuum statistics.  Index
+	 * passes add into these and the totals are reported once per index at the
+	 * end of the vacuum (see report_index_vacuum_extstats()).
+	 */
+	if (vacrel->nindexes > 0)
+	{
+		vacrel->extVacIdxReports =
+			palloc0_array(PgStat_VacuumRelationCounts, vacrel->nindexes);
+		vacrel->extVacIdxTouched = palloc0_array(bool, vacrel->nindexes);
+	}
 	if (instrument && vacrel->nindexes > 0)
 	{
 		/* Copy index names used by instrumentation (not error reporting) */
@@ -874,6 +1056,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel->aggressive = vacuum_get_cutoffs(rel, params, &vacrel->cutoffs);
 	vacrel->rel_pages = orig_rel_pages = RelationGetNumberOfBlocks(rel);
 	vacrel->vistest = GlobalVisTestFor(rel);
+	vacrel->wraparound_failsafe_count = 0;
 
 	/* Initialize state used to track oldest extant XID/MXID */
 	vacrel->NewRelfrozenXid = vacrel->cutoffs.OldestXmin;
@@ -1057,14 +1240,19 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	 * soon in cases where the failsafe prevented significant amounts of heap
 	 * vacuuming.
 	 */
+
+	/*
+	 * Make generic extended vacuum stats report and fill heap-specific
+	 * extended stats fields.
+	 */
+	extvac_stats_end(vacrel->rel, &extVacCounters, &extVacReport.common);
 	accumulate_heap_vacuum_statistics(vacrel, &extVacReport);
-	pgstat_report_vacuum_extstats(vacrel->reloid, rel->rd_rel->relisshared,
-								  &extVacReport);
+	pgstat_report_vacuum_extstats(vacrel->reloid, rel->rd_rel->relisshared, &extVacReport);
 	pgstat_report_vacuum(rel,
 						 Max(vacrel->new_live_tuples, 0),
 						 vacrel->recently_dead_tuples +
 						 vacrel->missed_dead_tuples,
-						 starttime);
+						 extVacCounters.starttime);
 	pgstat_progress_end_command();
 
 	if (instrument)
@@ -1072,7 +1260,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 		TimestampTz endtime = GetCurrentTimestamp();
 
 		if (verbose || params->log_vacuum_min_duration == 0 ||
-			TimestampDifferenceExceeds(starttime, endtime,
+			TimestampDifferenceExceeds(extVacCounters.starttime, endtime,
 									   params->log_vacuum_min_duration))
 		{
 			long		secs_dur;
@@ -1088,7 +1276,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 			int64		total_blks_read;
 			int64		total_blks_dirtied;
 
-			TimestampDifference(starttime, endtime, &secs_dur, &usecs_dur);
+			TimestampDifference(extVacCounters.starttime, endtime, &secs_dur, &usecs_dur);
 			memset(&walusage, 0, sizeof(WalUsage));
 			WalUsageAccumDiff(&walusage, &pgWalUsage, &startwalusage);
 			memset(&bufferusage, 0, sizeof(BufferUsage));
@@ -1698,7 +1886,8 @@ lazy_scan_heap(LVRelState *vacrel)
 	 * Report the per-index extended vacuum statistics accumulated over all
 	 * bulkdelete and cleanup passes, exactly once per index.  The parallel
 	 * path reports its DSM-resident totals from parallel_vacuum_end() instead,
-	 * so only do it here when index vacuuming ran in the leader.
+	 * so only do it here when index vacuuming ran in the leader.  pvs is still
+	 * alive at this point (it is torn down later in dead_items_cleanup()).
 	 */
 	if (vacrel->nindexes > 0 && !ParallelVacuumIsActive(vacrel))
 		report_index_vacuum_extstats(vacrel);
@@ -2619,7 +2808,7 @@ lazy_vacuum_all_indexes(LVRelState *vacrel)
 
 			vacrel->indstats[idx] = lazy_vacuum_one_index(indrel, istat,
 														  old_live_tuples,
-														  vacrel);
+														  vacrel, idx);
 
 			/* Report the number of indexes vacuumed */
 			pgstat_progress_update_param(PROGRESS_VACUUM_INDEXES_PROCESSED,
@@ -2635,11 +2824,21 @@ lazy_vacuum_all_indexes(LVRelState *vacrel)
 	}
 	else
 	{
+		LVExtStatCounters counters;
+		PgStat_VacuumRelationCounts extVacReport;
+
+		memset(&extVacReport.common, 0, sizeof(PgStat_CommonCounts));
+
+		extvac_stats_start(vacrel->rel, &counters);
+
 		/* Outsource everything to parallel variant */
 		parallel_vacuum_bulkdel_all_indexes(vacrel->pvs, old_live_tuples,
 											vacrel->num_index_scans,
 											&(vacrel->worker_usage.vacuum));
 
+		extvac_stats_end(vacrel->rel, &counters, &extVacReport.common);
+		accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+
 		/*
 		 * Do a postcheck to consider applying wraparound failsafe now.  Note
 		 * that parallel VACUUM only gets the precheck and this postcheck.
@@ -2987,6 +3186,7 @@ lazy_check_wraparound_failsafe(LVRelState *vacrel)
 		int64		progress_val[3] = {0, 0, PROGRESS_VACUUM_MODE_FAILSAFE};
 
 		VacuumFailsafeActive = true;
+		vacrel->wraparound_failsafe_count++;
 
 		/*
 		 * Abandon use of a buffer access strategy to allow use of all of
@@ -3060,7 +3260,7 @@ lazy_cleanup_all_indexes(LVRelState *vacrel)
 
 			vacrel->indstats[idx] =
 				lazy_cleanup_one_index(indrel, istat, reltuples,
-									   estimated_count, vacrel);
+									   estimated_count, vacrel, idx);
 
 			/* Report the number of indexes cleaned up */
 			pgstat_progress_update_param(PROGRESS_VACUUM_INDEXES_PROCESSED,
@@ -3069,11 +3269,21 @@ lazy_cleanup_all_indexes(LVRelState *vacrel)
 	}
 	else
 	{
+		LVExtStatCounters counters;
+		PgStat_VacuumRelationCounts extVacReport;
+
+		memset(&extVacReport.common, 0, sizeof(PgStat_CommonCounts));
+
+		extvac_stats_start(vacrel->rel, &counters);
+
 		/* Outsource everything to parallel variant */
 		parallel_vacuum_cleanup_all_indexes(vacrel->pvs, reltuples,
 											vacrel->num_index_scans,
 											estimated_count,
 											&(vacrel->worker_usage.cleanup));
+
+		extvac_stats_end(vacrel->rel, &counters, &extVacReport.common);
+		accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
 	}
 
 	/* Reset the progress counters */
@@ -3095,10 +3305,22 @@ lazy_cleanup_all_indexes(LVRelState *vacrel)
  */
 static IndexBulkDeleteResult *
 lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
-					  double reltuples, LVRelState *vacrel)
+					  double reltuples, LVRelState *vacrel, int idx)
 {
 	IndexVacuumInfo ivinfo;
 	LVSavedErrInfo saved_err_info;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
+
+	/*
+	 * Zero the report up front: extvac_stats_end_idx() leaves it untouched
+	 * when statistics tracking is disabled, but the accumulators below read it
+	 * unconditionally.
+	 */
+	memset(&extVacReport, 0, sizeof(PgStat_VacuumRelationCounts));
+
+	/* Set initial statistics values to gather vacuum statistics for the index */
+	extvac_stats_start_idx(indrel, istat, &extVacCounters);
 
 	ivinfo.index = indrel;
 	ivinfo.heaprel = vacrel->rel;
@@ -3125,6 +3347,19 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 	istat = vac_bulkdel_one_index(&ivinfo, istat, vacrel->dead_items,
 								  vacrel->dead_items_info);
 
+	/* Make extended vacuum stats report for index */
+	extvac_stats_end_idx(indrel, istat, &extVacCounters, &extVacReport);
+
+	/* This index's time must be excluded from the parent heap's totals */
+	accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+
+	/*
+	 * Accumulate this pass into the index's running totals.  They are reported
+	 * to the cumulative stats system once per index at the end of the vacuum.
+	 */
+	extvac_accumulate_idx_report(&vacrel->extVacIdxReports[idx], &extVacReport);
+	vacrel->extVacIdxTouched[idx] = true;
+
 	/* Revert to the previous phase information for error traceback */
 	restore_vacuum_error_info(vacrel, &saved_err_info);
 	pfree(vacrel->indname);
@@ -3145,10 +3380,22 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 static IndexBulkDeleteResult *
 lazy_cleanup_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 					   double reltuples, bool estimated_count,
-					   LVRelState *vacrel)
+					   LVRelState *vacrel, int idx)
 {
 	IndexVacuumInfo ivinfo;
 	LVSavedErrInfo saved_err_info;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
+
+	/*
+	 * Zero the report up front: extvac_stats_end_idx() leaves it untouched
+	 * when statistics tracking is disabled, but the accumulators below read it
+	 * unconditionally.
+	 */
+	memset(&extVacReport, 0, sizeof(PgStat_VacuumRelationCounts));
+
+	/* Set initial statistics values to gather vacuum statistics for the index */
+	extvac_stats_start_idx(indrel, istat, &extVacCounters);
 
 	ivinfo.index = indrel;
 	ivinfo.heaprel = vacrel->rel;
@@ -3174,6 +3421,19 @@ lazy_cleanup_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 
 	istat = vac_cleanup_one_index(&ivinfo, istat);
 
+	/* Make extended vacuum stats report for index */
+	extvac_stats_end_idx(indrel, istat, &extVacCounters, &extVacReport);
+
+	/* This index's time must be excluded from the parent heap's totals */
+	accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+
+	/*
+	 * Accumulate this pass into the index's running totals.  They are reported
+	 * to the cumulative stats system once per index at the end of the vacuum.
+	 */
+	extvac_accumulate_idx_report(&vacrel->extVacIdxReports[idx], &extVacReport);
+	vacrel->extVacIdxTouched[idx] = true;
+
 	/* Revert to the previous phase information for error traceback */
 	restore_vacuum_error_info(vacrel, &saved_err_info);
 	pfree(vacrel->indname);
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 21fa841f5a..b4fd8f90c6 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1575,7 +1575,11 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.missed_dead_tuples AS missed_dead_tuples,
         S.vm_new_frozen_pages AS vm_new_frozen_pages,
         S.vm_new_visible_pages AS vm_new_visible_pages,
-        S.vm_new_visible_frozen_pages AS vm_new_visible_frozen_pages
+        S.vm_new_visible_frozen_pages AS vm_new_visible_frozen_pages,
+        S.total_blks_read AS total_blks_read,
+        S.total_blks_hit AS total_blks_hit,
+        S.total_blks_dirtied AS total_blks_dirtied,
+        S.total_blks_written AS total_blks_written
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
@@ -1591,7 +1595,12 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             I.relname AS indexrelname,
 
             S.pages_deleted AS pages_deleted,
-            S.tuples_deleted AS tuples_deleted
+            S.tuples_deleted AS tuples_deleted,
+
+            S.total_blks_read AS total_blks_read,
+            S.total_blks_hit AS total_blks_hit,
+            S.total_blks_dirtied AS total_blks_dirtied,
+            S.total_blks_written AS total_blks_written
     FROM
             pg_class C JOIN
             pg_index X ON C.oid = X.indrelid JOIN
@@ -1599,3 +1608,18 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace),
             LATERAL pg_stat_get_vacuum_indexes(I.oid) S
     WHERE C.relkind IN ('r', 't', 'm');
+
+CREATE VIEW pg_stat_vacuum_database AS
+    SELECT
+            D.oid AS dboid,
+            D.datname AS dbname,
+
+            S.errors AS errors,
+
+            S.db_blks_read AS db_blks_read,
+            S.db_blks_hit AS db_blks_hit,
+            S.total_blks_dirtied AS total_blks_dirtied,
+            S.total_blks_written AS total_blks_written
+    FROM
+            pg_database D,
+            LATERAL pg_stat_get_vacuum_database(D.oid) S;
diff --git a/src/backend/commands/dbcommands.c b/src/backend/commands/dbcommands.c
index f0819d15ab..49c3b40c35 100644
--- a/src/backend/commands/dbcommands.c
+++ b/src/backend/commands/dbcommands.c
@@ -1830,6 +1830,7 @@ dropdb(const char *dbname, bool missing_ok, bool force)
 	 * Tell the cumulative stats system to forget it immediately, too.
 	 */
 	pgstat_drop_database(db_id);
+	pgstat_drop_vacuum_database(db_id);
 
 	/*
 	 * Except for the deletion of the catalog row, subsequent actions are not
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index a4abb29cf6..aacecee58a 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -118,6 +118,9 @@ pg_atomic_uint32 *VacuumSharedCostBalance = NULL;
 pg_atomic_uint32 *VacuumActiveNWorkers = NULL;
 int			VacuumCostBalanceLocal = 0;
 
+/* Cumulative storage to report total vacuum delay time. */
+double		VacuumDelayTime = 0;	/* msec. */
+
 /* non-export function prototypes */
 static List *expand_vacuum_rel(VacuumRelation *vrel,
 							   MemoryContext vac_context, int options);
@@ -2566,6 +2569,7 @@ vacuum_delay_point(bool is_analyze)
 			exit(1);
 
 		VacuumCostBalance = 0;
+		VacuumDelayTime += msec;
 
 		/*
 		 * Balance and update limit values for autovacuum workers. We must do
diff --git a/src/backend/commands/vacuumparallel.c b/src/backend/commands/vacuumparallel.c
index 7725c4ecc1..6c229564e0 100644
--- a/src/backend/commands/vacuumparallel.c
+++ b/src/backend/commands/vacuumparallel.c
@@ -1303,6 +1303,7 @@ parallel_vacuum_main(dsm_segment *seg, shm_toc *toc)
 		VacuumUpdateCosts();
 
 	VacuumCostBalance = 0;
+	VacuumDelayTime = 0;
 	VacuumCostBalanceLocal = 0;
 	VacuumSharedCostBalance = &(shared->cost_balance);
 	VacuumActiveNWorkers = &(shared->active_nworkers);
diff --git a/src/backend/utils/activity/pgstat.c b/src/backend/utils/activity/pgstat.c
index 4e950672e2..3741e4e54a 100644
--- a/src/backend/utils/activity/pgstat.c
+++ b/src/backend/utils/activity/pgstat.c
@@ -500,6 +500,21 @@ static const PgStat_KindInfo pgstat_kind_builtin_infos[PGSTAT_KIND_BUILTIN_SIZE]
 		.reset_all_cb = pgstat_wal_reset_all_cb,
 		.snapshot_cb = pgstat_wal_snapshot_cb,
 	},
+	[PGSTAT_KIND_VACUUM_DB] = {
+		.name = "vacuum statistics",
+
+		.fixed_amount = false,
+		.write_to_file = true,
+		/* so pg_stat_database entries can be seen in all databases */
+		.accessed_across_databases = true,
+
+		.shared_size = sizeof(PgStatShared_VacuumDB),
+		.shared_data_off = offsetof(PgStatShared_VacuumDB, stats),
+		.shared_data_len = sizeof(((PgStatShared_VacuumDB *) 0)->stats),
+		.pending_size = sizeof(PgStat_VacuumDBCounts),
+
+		.flush_pending_cb = pgstat_vacuum_db_flush_cb,
+	},
 	[PGSTAT_KIND_VACUUM_RELATION] = {
 		.name = "vacuum statistics",
 
diff --git a/src/backend/utils/activity/pgstat_database.c b/src/backend/utils/activity/pgstat_database.c
index 7f3bc01659..cdff619200 100644
--- a/src/backend/utils/activity/pgstat_database.c
+++ b/src/backend/utils/activity/pgstat_database.c
@@ -46,6 +46,15 @@ pgstat_drop_database(Oid databaseid)
 	pgstat_drop_transactional(PGSTAT_KIND_DATABASE, databaseid, InvalidOid);
 }
 
+/*
+ * Remove entry for the database being dropped.
+ */
+void
+pgstat_drop_vacuum_database(Oid databaseid)
+{
+	pgstat_drop_transactional(PGSTAT_KIND_VACUUM_DB, databaseid, InvalidOid);
+}
+
 /*
  * Called from autovacuum.c to report startup of an autovacuum process.
  * We are called before InitPostgres is done, so can't rely on MyDatabaseId;
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 4f6cbfd540..21678bf646 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -7,7 +7,7 @@
  * kept separate from pgstat_relation.c and pgstat_database.c to reduce the
  * memory footprint of the regular relation and database statistics: vacuum
  * metrics require significantly more space per relation, so they live in their
- * own PGSTAT_KIND_VACUUM_RELATION stats kind.
+ * own PGSTAT_KIND_VACUUM_RELATION and PGSTAT_KIND_VACUUM_DB stats kinds.
  *
  * Copyright (c) 2001-2026, PostgreSQL Global Development Group
  *
@@ -22,15 +22,32 @@
 #include "utils/memutils.h"
 #include "utils/pgstat_internal.h"
 
+/* ----------
+ * GUC parameters
+ * ----------
+ */
+bool		pgstat_track_vacuum_statistics_for_relations = false;
+
+#define ACCUMULATE_FIELD(field) (dst->field += src->field)
 #define ACCUMULATE_SUBFIELD(substruct, field) (dst->substruct.field += src->substruct.field)
 
 /*
- * Accumulate the per-table extended vacuum counters collected so far.
- *
- * Only the counters derived directly from the vacuum's own bookkeeping are
- * summed here.  The buffer, WAL and timing counters (and the per-index
- * counters) are accumulated by additional code added together with the
- * helpers that gather them.
+ * Accumulate the counters that are common to heap relations, indexes and
+ * databases.
+ */
+static void
+pgstat_accumulate_common(PgStat_CommonCounts *dst, const PgStat_CommonCounts *src)
+{
+	ACCUMULATE_FIELD(total_blks_read);
+	ACCUMULATE_FIELD(total_blks_hit);
+	ACCUMULATE_FIELD(total_blks_dirtied);
+	ACCUMULATE_FIELD(total_blks_written);
+
+	ACCUMULATE_FIELD(tuples_deleted);
+}
+
+/*
+ * Accumulate per-relation (heap or index) extended vacuum counters.
  */
 static void
 pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
@@ -46,19 +63,19 @@ pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
 		   src->type != PGSTAT_EXTVAC_DB &&
 		   src->type == dst->type);
 
-	ACCUMULATE_SUBFIELD(common, tuples_deleted);
+	pgstat_accumulate_common(&dst->common, &src->common);
 
 	if (dst->type == PGSTAT_EXTVAC_TABLE)
 	{
 		ACCUMULATE_SUBFIELD(table, pages_scanned);
 		ACCUMULATE_SUBFIELD(table, pages_removed);
-		ACCUMULATE_SUBFIELD(table, tuples_frozen);
-		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
-		ACCUMULATE_SUBFIELD(table, missed_dead_pages);
-		ACCUMULATE_SUBFIELD(table, missed_dead_tuples);
-		ACCUMULATE_SUBFIELD(table, vm_new_frozen_pages);
+				ACCUMULATE_SUBFIELD(table, vm_new_frozen_pages);
 		ACCUMULATE_SUBFIELD(table, vm_new_visible_pages);
 		ACCUMULATE_SUBFIELD(table, vm_new_visible_frozen_pages);
+		ACCUMULATE_SUBFIELD(table, missed_dead_pages);
+			ACCUMULATE_SUBFIELD(table, tuples_frozen);
+		ACCUMULATE_SUBFIELD(table, recently_dead_tuples);
+		ACCUMULATE_SUBFIELD(table, missed_dead_tuples);
 	}
 	else if (dst->type == PGSTAT_EXTVAC_INDEX)
 	{
@@ -67,8 +84,22 @@ pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
 }
 
 /*
- * Report that the relation was just vacuumed, accumulating its extended
- * statistics into the per-relation entry.
+ * Accumulate per-database extended vacuum counters.
+ */
+static void
+pgstat_accumulate_extvac_stats_db(PgStat_VacuumDBCounts *dst,
+								  PgStat_VacuumDBCounts *src)
+{
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	pgstat_accumulate_common(&dst->common, &src->common);
+	dst->errors += src->errors;
+}
+
+/*
+ * Report that the relation was just vacuumed, accumulating both its own
+ * extended statistics and the database-wide aggregate.
  */
 void
 pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
@@ -76,16 +107,25 @@ pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
 {
 	PgStat_EntryRef *entry_ref;
 	PgStatShared_VacuumRelation *shtabentry;
+	PgStatShared_VacuumDB *shdbentry;
 	Oid			dboid = (shared ? InvalidOid : MyDatabaseId);
 
 	if (!pgstat_track_vacuum_statistics)
 		return;
 
+	/* Per-relation extended vacuum statistics */
 	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_VACUUM_RELATION,
 											dboid, tableoid, false);
 	shtabentry = (PgStatShared_VacuumRelation *) entry_ref->shared_stats;
 	pgstat_accumulate_extvac_stats_relations(&shtabentry->stats, params);
 	pgstat_unlock_entry(entry_ref);
+
+	/* Database-wide aggregate of the same work */
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_VACUUM_DB,
+											dboid, InvalidOid, false);
+	shdbentry = (PgStatShared_VacuumDB *) entry_ref->shared_stats;
+	pgstat_accumulate_common(&shdbentry->stats.common, &params->common);
+	pgstat_unlock_entry(entry_ref);
 }
 
 /*
@@ -119,6 +159,31 @@ pgstat_vacuum_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
 	return true;
 }
 
+/*
+ * Flush out pending per-database extended vacuum stats for the entry.
+ */
+bool
+pgstat_vacuum_db_flush_cb(PgStat_EntryRef *entry_ref, bool nowait)
+{
+	PgStatShared_VacuumDB *sharedent;
+	PgStat_VacuumDBCounts *pendingent;
+
+	pendingent = (PgStat_VacuumDBCounts *) entry_ref->pending;
+	sharedent = (PgStatShared_VacuumDB *) entry_ref->shared_stats;
+
+	if (pg_memory_is_all_zeros(pendingent, sizeof(PgStat_VacuumDBCounts)))
+		return true;
+
+	if (!pgstat_lock_entry(entry_ref, nowait))
+		return false;
+
+	pgstat_accumulate_extvac_stats_db(&sharedent->stats, pendingent);
+
+	pgstat_unlock_entry(entry_ref);
+
+	return true;
+}
+
 /*
  * Support function for the SQL-callable pgstat* functions. Returns the vacuum
  * collected statistics for one relation or NULL.
@@ -129,3 +194,14 @@ pgstat_fetch_stat_vacuum_tabentry(Oid relid, Oid dbid)
 	return (PgStat_VacuumRelationCounts *)
 		pgstat_fetch_entry(PGSTAT_KIND_VACUUM_RELATION, dbid, relid, NULL);
 }
+
+/*
+ * Support function for the SQL-callable pgstat* functions. Returns the vacuum
+ * collected statistics for one database or NULL.
+ */
+PgStat_VacuumDBCounts *
+pgstat_fetch_stat_vacuum_dbentry(Oid dbid)
+{
+	return (PgStat_VacuumDBCounts *)
+		pgstat_fetch_entry(PGSTAT_KIND_VACUUM_DB, dbid, InvalidOid, NULL);
+}
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 3acf0a7391..fc32b20850 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 11
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 15
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2410,6 +2410,10 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->table.vm_new_frozen_pages);
 	values[i++] = Int64GetDatum(extvacuum->table.vm_new_visible_pages);
 	values[i++] = Int64GetDatum(extvacuum->table.vm_new_visible_frozen_pages);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_read);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
@@ -2423,7 +2427,7 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 3
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 7
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2451,8 +2455,53 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->index.pages_deleted);
 	values[i++] = Int64GetDatum(extvacuum->common.tuples_deleted);
 
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_read);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
+
 	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
 
 	/* Returns the record as Datum */
 	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
 }
+
+/*
+ * Get the extended vacuum statistics for a database.
+ */
+Datum
+pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
+{
+#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 6
+
+	Oid			dbid = PG_GETARG_OID(0);
+	PgStat_VacuumDBCounts *extvacuum;
+	TupleDesc	tupdesc;
+	Datum		values[PG_STAT_GET_VACUUM_DATABASE_STATS_COLS] = {0};
+	bool		nulls[PG_STAT_GET_VACUUM_DATABASE_STATS_COLS] = {0};
+	int			i = 0;
+
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "return type must be a row type");
+
+	extvacuum = OidIsValid(dbid) ? pgstat_fetch_stat_vacuum_dbentry(dbid) : NULL;
+	if (!extvacuum)
+	{
+		InitMaterializedSRF(fcinfo, 0);
+		PG_RETURN_VOID();
+	}
+
+	values[i++] = ObjectIdGetDatum(dbid);
+
+	values[i++] = Int32GetDatum(extvacuum->errors);
+
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_read);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
+	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
+
+	Assert(i == PG_STAT_GET_VACUUM_DATABASE_STATS_COLS);
+
+	/* Returns the record as Datum */
+	PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls)));
+}
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 7d87b03239..edf3cc8b62 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
@@ -12718,8 +12718,17 @@
   proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8}',
-  proargmodes => '{i,o,o,o}',
-  proargnames => '{reloid,relid,pages_deleted,tuples_deleted}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written}',
   prosrc => 'pg_stat_get_vacuum_indexes' },
+{ oid => '8005',
+  descr => 'pg_stat_get_vacuum_database returns vacuum stats values for database',
+  proname => 'pg_stat_get_vacuum_database', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
+  proretset => 't',
+  proargtypes => 'oid',
+  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o}',
+  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written}',
+  prosrc => 'pg_stat_get_vacuum_database' },
 ]
diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h
index 956d9cea36..f0d08e1a64 100644
--- a/src/include/commands/vacuum.h
+++ b/src/include/commands/vacuum.h
@@ -21,9 +21,11 @@
 #include "catalog/pg_class.h"
 #include "catalog/pg_statistic.h"
 #include "catalog/pg_type.h"
+#include "executor/instrument.h"
 #include "parser/parse_node.h"
 #include "storage/buf.h"
 #include "utils/relcache.h"
+#include "pgstat.h"
 
 /*
  * Flags for amparallelvacuumoptions to control the participation of bulkdelete
@@ -321,6 +323,26 @@ typedef struct PVWorkerUsage
 	PVWorkerStats cleanup;
 } PVWorkerUsage;
 
+/*
+ * Counters and usage data for extended stats tracking.
+ */
+typedef struct LVExtStatCounters
+{
+	TimestampTz starttime;
+	WalUsage	walusage;
+	BufferUsage bufusage;
+	double		VacuumDelayTime;
+	PgStat_Counter blocks_fetched;
+	PgStat_Counter blocks_hit;
+}			LVExtStatCounters;
+
+typedef struct LVExtStatCountersIdx
+{
+	LVExtStatCounters common;
+	int64		pages_deleted;
+	int64		tuples_removed;
+}			LVExtStatCountersIdx;
+
 /* GUC parameters */
 extern PGDLLIMPORT int default_statistics_target;	/* PGDLLIMPORT for PostGIS */
 extern PGDLLIMPORT int vacuum_freeze_min_age;
@@ -353,6 +375,7 @@ extern PGDLLIMPORT double vacuum_max_eager_freeze_failure_rate;
 extern PGDLLIMPORT pg_atomic_uint32 *VacuumSharedCostBalance;
 extern PGDLLIMPORT pg_atomic_uint32 *VacuumActiveNWorkers;
 extern PGDLLIMPORT int VacuumCostBalanceLocal;
+extern PGDLLIMPORT double VacuumDelayTime;
 
 extern PGDLLIMPORT bool VacuumFailsafeActive;
 extern PGDLLIMPORT double vacuum_cost_delay;
@@ -439,4 +462,10 @@ extern double anl_random_fract(void);
 extern double anl_init_selection_state(int n);
 extern double anl_get_next_S(double t, int n, double *stateptr);
 
+extern void extvac_stats_start_idx(Relation rel, IndexBulkDeleteResult *stats,
+								   LVExtStatCountersIdx * counters);
+extern void extvac_stats_end_idx(Relation rel, IndexBulkDeleteResult *stats,
+								 LVExtStatCountersIdx * counters, PgStat_VacuumRelationCounts * report);
+extern void extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
+										 const PgStat_VacuumRelationCounts * src);
 #endif							/* VACUUM_H */
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index f2bdef1463..6a9c9800b7 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -175,6 +175,12 @@ typedef struct PgStat_TableCounts
 
 typedef struct PgStat_CommonCounts
 {
+	/* blocks */
+	int64		total_blks_read;
+	int64		total_blks_hit;
+	int64		total_blks_dirtied;
+	int64		total_blks_written;
+
 	/* tuples */
 	int64		tuples_deleted;
 }			PgStat_CommonCounts;
@@ -244,6 +250,13 @@ typedef struct PgStat_VacuumRelationStatus
 	PgStat_VacuumRelationCounts counts; /* event counts to be sent */
 }			PgStat_VacuumRelationStatus;
 
+typedef struct PgStat_VacuumDBCounts
+{
+	Oid			dbjid;
+	PgStat_CommonCounts common;
+	int32		errors;
+}			PgStat_VacuumDBCounts;
+
 /* ----------
  * PgStat_TableStatus			Per-table status within a backend
  *
@@ -924,11 +937,13 @@ extern int	pgstat_get_transactional_drops(bool isCommit, struct xl_xact_stats_it
 extern void pgstat_execute_transactional_drops(int ndrops, struct xl_xact_stats_item *items, bool is_redo);
 
 
+extern void pgstat_drop_vacuum_database(Oid databaseid);
 extern void pgstat_vacuum_relation_delete_pending_cb(Oid relid);
 extern void
 			pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
 										  PgStat_VacuumRelationCounts * params);
 extern PgStat_VacuumRelationCounts * pgstat_fetch_stat_vacuum_tabentry(Oid relid, Oid dbid);
+extern PgStat_VacuumDBCounts * pgstat_fetch_stat_vacuum_dbentry(Oid dbid);
 
 /*
  * Functions in pgstat_wal.c
@@ -947,6 +962,7 @@ extern PGDLLIMPORT bool pgstat_track_counts;
 extern PGDLLIMPORT int pgstat_track_functions;
 extern PGDLLIMPORT int pgstat_fetch_consistency;
 extern PGDLLIMPORT bool pgstat_track_vacuum_statistics;
+extern PGDLLIMPORT bool pgstat_track_vacuum_statistics_for_relations;
 
 /*
  * Variables in pgstat_bgwriter.c
diff --git a/src/include/utils/pgstat_internal.h b/src/include/utils/pgstat_internal.h
index 46a127af2b..a234c797e0 100644
--- a/src/include/utils/pgstat_internal.h
+++ b/src/include/utils/pgstat_internal.h
@@ -507,6 +507,12 @@ typedef struct PgStatShared_Relation
 	PgStat_StatTabEntry stats;
 } PgStatShared_Relation;
 
+typedef struct PgStatShared_VacuumDB
+{
+	PgStatShared_Common header;
+	PgStat_VacuumDBCounts stats;
+}			PgStatShared_VacuumDB;
+
 typedef struct PgStatShared_VacuumRelation
 {
 	PgStatShared_Common header;
@@ -695,6 +701,7 @@ extern void *pgstat_fetch_entry(PgStat_Kind kind, Oid dboid, uint64 objid,
 								bool *may_free);
 extern void pgstat_snapshot_fixed(PgStat_Kind kind);
 
+bool		pgstat_vacuum_db_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
 extern bool pgstat_vacuum_relation_flush_cb(PgStat_EntryRef *entry_ref, bool nowait);
 
 
diff --git a/src/include/utils/pgstat_kind.h b/src/include/utils/pgstat_kind.h
index f92149066c..52d9367fbe 100644
--- a/src/include/utils/pgstat_kind.h
+++ b/src/include/utils/pgstat_kind.h
@@ -40,9 +40,10 @@
 #define PGSTAT_KIND_SLRU	12
 #define PGSTAT_KIND_WAL	13
 #define PGSTAT_KIND_VACUUM_RELATION	14
+#define PGSTAT_KIND_VACUUM_DB	15
 
 #define PGSTAT_KIND_BUILTIN_MIN PGSTAT_KIND_DATABASE
-#define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_VACUUM_RELATION
+#define PGSTAT_KIND_BUILTIN_MAX PGSTAT_KIND_VACUUM_DB
 #define PGSTAT_KIND_BUILTIN_SIZE (PGSTAT_KIND_BUILTIN_MAX + 1)
 
 /* Custom stats kinds */
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index c30ff6c72f..7b67fe72a6 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2421,18 +2421,31 @@ pg_stat_user_tables| SELECT relid,
     frozen_page_marks_cleared
    FROM pg_stat_all_tables
   WHERE ((schemaname <> ALL (ARRAY['pg_catalog'::name, 'information_schema'::name])) AND (schemaname !~ '^pg_toast'::text));
+pg_stat_vacuum_database| SELECT d.oid AS dboid,
+    d.datname AS dbname,
+    s.errors,
+    s.db_blks_read,
+    s.db_blks_hit,
+    s.total_blks_dirtied,
+    s.total_blks_written
+   FROM pg_database d,
+    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written);
 pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     i.oid AS indexrelid,
     n.nspname AS schemaname,
     c.relname,
     i.relname AS indexrelname,
     s.pages_deleted,
-    s.tuples_deleted
+    s.tuples_deleted,
+    s.total_blks_read,
+    s.total_blks_hit,
+    s.total_blks_dirtied,
+    s.total_blks_written
    FROM (((pg_class c
      JOIN pg_index x ON ((c.oid = x.indrelid)))
      JOIN pg_class i ON ((i.oid = x.indexrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted)
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     c.relname,
@@ -2446,10 +2459,14 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.missed_dead_tuples,
     s.vm_new_frozen_pages,
     s.vm_new_visible_pages,
-    s.vm_new_visible_frozen_pages
+    s.vm_new_visible_frozen_pages,
+    s.total_blks_read,
+    s.total_blks_hit,
+    s.total_blks_dirtied,
+    s.total_blks_written
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index 0b3354cfff..6c46239eb6 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -99,16 +99,37 @@ SELECT vm_new_visible_pages > 0 AS vm_new_visible_pages,
 (1 row)
 
 DROP TABLE vacstat_frz;
+-- total buffer access counters.  The vacuum always touches the table's pages
+-- through the buffer cache (total_blks_hit > 0) and dirties some of them while
+-- removing dead tuples (total_blks_dirtied > 0).  total_blks_read and
+-- total_blks_written depend on the buffer-cache and checkpoint state at run
+-- time, so they are only checked for being non-negative.
+SELECT total_blks_read >= 0 AS total_blks_read,
+       total_blks_hit > 0 AS total_blks_hit,
+       total_blks_dirtied > 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written 
+-----------------+----------------+--------------------+--------------------
+ t               | t              | t                  | t
+(1 row)
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
--- while every index entry for a removed heap tuple is deleted.
+-- while every index entry for a removed heap tuple is deleted.  The index is
+-- read through the buffer cache (total_blks_hit > 0); the read/written/dirtied
+-- counters depend on run-time cache state.
 SELECT indexrelname,
        pages_deleted = 0 AS pages_deleted,
-       tuples_deleted = 500 AS tuples_deleted
+       tuples_deleted = 500 AS tuples_deleted,
+       total_blks_read >= 0 AS total_blks_read,
+       total_blks_hit > 0 AS total_blks_hit,
+       total_blks_dirtied >= 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
-  indexrelname  | pages_deleted | tuples_deleted 
-----------------+---------------+----------------
- vacstat_t_pkey | t             | t
+  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written 
+----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------
+ vacstat_t_pkey | t             | t              | t               | t              | t                  | t
 (1 row)
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -130,3 +151,17 @@ SELECT indexrelname,
 (1 row)
 
 DROP TABLE vacstat_idxdel;
+-- per-database aggregate view: no vacuum errors occurred in this database, and
+-- the vacuums in this database touched pages through the buffer cache
+-- (db_blks_hit > 0).
+SELECT errors = 0 AS errors,
+       db_blks_read >= 0 AS db_blks_read,
+       db_blks_hit > 0 AS db_blks_hit,
+       total_blks_dirtied >= 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
+  FROM pg_stat_vacuum_database WHERE dbname = current_database();
+ errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written 
+--------+--------------+-------------+--------------------+--------------------
+ t      | t            | t           | t                  | t
+(1 row)
+
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index db80ecf6a1..91079759ea 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -77,12 +77,29 @@ SELECT vm_new_visible_pages > 0 AS vm_new_visible_pages,
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_frz';
 DROP TABLE vacstat_frz;
 
+-- total buffer access counters.  The vacuum always touches the table's pages
+-- through the buffer cache (total_blks_hit > 0) and dirties some of them while
+-- removing dead tuples (total_blks_dirtied > 0).  total_blks_read and
+-- total_blks_written depend on the buffer-cache and checkpoint state at run
+-- time, so they are only checked for being non-negative.
+SELECT total_blks_read >= 0 AS total_blks_read,
+       total_blks_hit > 0 AS total_blks_hit,
+       total_blks_dirtied > 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
--- while every index entry for a removed heap tuple is deleted.
+-- while every index entry for a removed heap tuple is deleted.  The index is
+-- read through the buffer cache (total_blks_hit > 0); the read/written/dirtied
+-- counters depend on run-time cache state.
 SELECT indexrelname,
        pages_deleted = 0 AS pages_deleted,
-       tuples_deleted = 500 AS tuples_deleted
+       tuples_deleted = 500 AS tuples_deleted,
+       total_blks_read >= 0 AS total_blks_read,
+       total_blks_hit > 0 AS total_blks_hit,
+       total_blks_dirtied >= 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -99,3 +116,13 @@ SELECT indexrelname,
        tuples_deleted = 9000 AS tuples_deleted
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_idxdel' ORDER BY indexrelname;
 DROP TABLE vacstat_idxdel;
+
+-- per-database aggregate view: no vacuum errors occurred in this database, and
+-- the vacuums in this database touched pages through the buffer cache
+-- (db_blks_hit > 0).
+SELECT errors = 0 AS errors,
+       db_blks_read >= 0 AS db_blks_read,
+       db_blks_hit > 0 AS db_blks_hit,
+       total_blks_dirtied >= 0 AS total_blks_dirtied,
+       total_blks_written >= 0 AS total_blks_written
+  FROM pg_stat_vacuum_database WHERE dbname = current_database();
-- 
2.39.5 (Apple Git-154)



  [text/plain] 0006-Extended-vacuum-statistics-per-relation-buffer-acces.patch (16.4K, 8-0006-Extended-vacuum-statistics-per-relation-buffer-acces.patch)
  download | inline diff:
From 791dacf1cc81105971add3e7dee45016c4bc7c79 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:52:11 +0300
Subject: [PATCH 6/8] Extended vacuum statistics: per-relation buffer access
 for tables and indexes

Expose the per-relation buffer access counters in pg_stat_vacuum_tables and
pg_stat_vacuum_indexes, with documentation and regression coverage:

  rel_blks_read  this relation's blocks read from disk by the vacuum
  rel_blks_hit   this relation's blocks found in shared buffers

Unlike total_blks_*, which count all shared-buffer access during the vacuum,
these are restricted to the target relation's own blocks.
---
 doc/src/sgml/system-views.sgml             | 32 ++++++++++++++++++++++
 src/backend/access/heap/vacuumlazy.c       | 15 ++++++++++
 src/backend/catalog/system_views.sql       |  9 ++++--
 src/backend/utils/activity/pgstat_vacuum.c |  3 ++
 src/backend/utils/adt/pgstatfuncs.c        |  9 ++++--
 src/include/catalog/pg_proc.dat            | 12 ++++----
 src/include/pgstat.h                       |  4 +++
 src/test/regress/expected/rules.out        | 12 +++++---
 src/test/regress/expected/vacuum_stats.out | 20 +++++++++++---
 src/test/regress/sql/vacuum_stats.sql      | 10 ++++++-
 10 files changed, 107 insertions(+), 19 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 088b532e5c..8e80db3e03 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5948,6 +5948,22 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of shared buffer blocks written out by the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of this relation's blocks read from disk by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of this relation's blocks found in shared buffers by the vacuum.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6071,6 +6087,22 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of shared buffer blocks written out while vacuuming the index.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_read</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of blocks of this index read from disk by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_hit</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of block hits within this index during the vacuum.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 9c524909fe..fdd70653e3 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -569,6 +569,19 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 	report->total_blks_hit += bufusage.local_blks_hit + bufusage.shared_blks_hit;
 	report->total_blks_dirtied += bufusage.local_blks_dirtied + bufusage.shared_blks_dirtied;
 	report->total_blks_written += bufusage.shared_blks_written;
+
+	if (!rel->pgstat_info || !pgstat_track_counts)
+
+		/*
+		 * if something goes wrong or an user doesn't want to track a database
+		 * activity - just suppress it.
+		 */
+		return;
+
+	report->blks_fetched +=
+		rel->pgstat_info->counts.blocks_fetched - counters->blocks_fetched;
+	report->blks_hit +=
+		rel->pgstat_info->counts.blocks_hit - counters->blocks_hit;
 }
 
 void
@@ -691,6 +704,8 @@ extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
 	dst->common.total_blks_hit += src->common.total_blks_hit;
 	dst->common.total_blks_dirtied += src->common.total_blks_dirtied;
 	dst->common.total_blks_written += src->common.total_blks_written;
+	dst->common.blks_fetched += src->common.blks_fetched;
+	dst->common.blks_hit += src->common.blks_hit;
 	dst->common.tuples_deleted += src->common.tuples_deleted;
 
 	dst->index.pages_deleted += src->index.pages_deleted;
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index b4fd8f90c6..e7d4165844 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1579,7 +1579,9 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.total_blks_read AS total_blks_read,
         S.total_blks_hit AS total_blks_hit,
         S.total_blks_dirtied AS total_blks_dirtied,
-        S.total_blks_written AS total_blks_written
+        S.total_blks_written AS total_blks_written,
+        S.rel_blks_read AS rel_blks_read,
+        S.rel_blks_hit AS rel_blks_hit
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
@@ -1600,7 +1602,10 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             S.total_blks_read AS total_blks_read,
             S.total_blks_hit AS total_blks_hit,
             S.total_blks_dirtied AS total_blks_dirtied,
-            S.total_blks_written AS total_blks_written
+            S.total_blks_written AS total_blks_written,
+
+            S.rel_blks_read AS rel_blks_read,
+            S.rel_blks_hit AS rel_blks_hit
     FROM
             pg_class C JOIN
             pg_index X ON C.oid = X.indrelid JOIN
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 21678bf646..9b1eaf0f4a 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -43,6 +43,9 @@ pgstat_accumulate_common(PgStat_CommonCounts *dst, const PgStat_CommonCounts *sr
 	ACCUMULATE_FIELD(total_blks_dirtied);
 	ACCUMULATE_FIELD(total_blks_written);
 
+	ACCUMULATE_FIELD(blks_fetched);
+	ACCUMULATE_FIELD(blks_hit);
+
 	ACCUMULATE_FIELD(tuples_deleted);
 }
 
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index fc32b20850..4f0be66621 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 15
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 17
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2414,6 +2414,8 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_hit);
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
+	values[i++] = Int64GetDatum(extvacuum->common.blks_fetched - extvacuum->common.blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.blks_hit);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
@@ -2427,7 +2429,7 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 7
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 9
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2460,6 +2462,9 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
 
+	values[i++] = Int64GetDatum(extvacuum->common.blks_fetched - extvacuum->common.blks_hit);
+	values[i++] = Int64GetDatum(extvacuum->common.blks_hit);
+
 	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
 
 	/* Returns the record as Datum */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index edf3cc8b62..14f070a941 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
@@ -12718,9 +12718,9 @@
   proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit}',
   prosrc => 'pg_stat_get_vacuum_indexes' },
 { oid => '8005',
   descr => 'pg_stat_get_vacuum_database returns vacuum stats values for database',
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index 6a9c9800b7..de38949d12 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -181,6 +181,10 @@ typedef struct PgStat_CommonCounts
 	int64		total_blks_dirtied;
 	int64		total_blks_written;
 
+	/* heap blocks */
+	int64		blks_fetched;
+	int64		blks_hit;
+
 	/* tuples */
 	int64		tuples_deleted;
 }			PgStat_CommonCounts;
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 7b67fe72a6..0b28e2ffc3 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2440,12 +2440,14 @@ pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     s.total_blks_read,
     s.total_blks_hit,
     s.total_blks_dirtied,
-    s.total_blks_written
+    s.total_blks_written,
+    s.rel_blks_read,
+    s.rel_blks_hit
    FROM (((pg_class c
      JOIN pg_index x ON ((c.oid = x.indrelid)))
      JOIN pg_class i ON ((i.oid = x.indexrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written)
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     c.relname,
@@ -2463,10 +2465,12 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.total_blks_read,
     s.total_blks_hit,
     s.total_blks_dirtied,
-    s.total_blks_written
+    s.total_blks_written,
+    s.rel_blks_read,
+    s.rel_blks_hit
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index 6c46239eb6..bf2fbc7626 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -114,6 +114,16 @@ SELECT total_blks_read >= 0 AS total_blks_read,
  t               | t              | t                  | t
 (1 row)
 
+-- per-relation buffer access.  The heap is read through the buffer cache
+-- (rel_blks_hit > 0); rel_blks_read depends on the run-time cache state.
+SELECT rel_blks_read >= 0 AS rel_blks_read,
+       rel_blks_hit > 0 AS rel_blks_hit
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ rel_blks_read | rel_blks_hit 
+---------------+--------------
+ t             | t
+(1 row)
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -125,11 +135,13 @@ SELECT indexrelname,
        total_blks_read >= 0 AS total_blks_read,
        total_blks_hit > 0 AS total_blks_hit,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
-       total_blks_written >= 0 AS total_blks_written
+       total_blks_written >= 0 AS total_blks_written,
+       rel_blks_read >= 0 AS rel_blks_read,
+       rel_blks_hit > 0 AS rel_blks_hit
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
-  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written 
-----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------
- vacstat_t_pkey | t             | t              | t               | t              | t                  | t
+  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit 
+----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------
+ vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t
 (1 row)
 
 -- index page-deletion path: deleting a contiguous key range empties whole
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index 91079759ea..49ed3b4063 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -88,6 +88,12 @@ SELECT total_blks_read >= 0 AS total_blks_read,
        total_blks_written >= 0 AS total_blks_written
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
 
+-- per-relation buffer access.  The heap is read through the buffer cache
+-- (rel_blks_hit > 0); rel_blks_read depends on the run-time cache state.
+SELECT rel_blks_read >= 0 AS rel_blks_read,
+       rel_blks_hit > 0 AS rel_blks_hit
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -99,7 +105,9 @@ SELECT indexrelname,
        total_blks_read >= 0 AS total_blks_read,
        total_blks_hit > 0 AS total_blks_hit,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
-       total_blks_written >= 0 AS total_blks_written
+       total_blks_written >= 0 AS total_blks_written,
+       rel_blks_read >= 0 AS rel_blks_read,
+       rel_blks_hit > 0 AS rel_blks_hit
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
 
 -- index page-deletion path: deleting a contiguous key range empties whole
-- 
2.39.5 (Apple Git-154)



  [text/plain] 0007-Extended-vacuum-statistics-timing-metrics-and-failsa.patch (35.3K, 9-0007-Extended-vacuum-statistics-timing-metrics-and-failsa.patch)
  download | inline diff:
From 42dffa8754bc2a554bec5a9662f5afdbb379d6f2 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:53:03 +0300
Subject: [PATCH 7/8] Extended vacuum statistics: timing metrics and failsafe
 for tables, indexes and database

Expose the timing counters and the wraparound failsafe count in
pg_stat_vacuum_tables, pg_stat_vacuum_indexes and pg_stat_vacuum_database,
with documentation and regression coverage:

  blk_read_time   time spent reading blocks (with track_io_timing)
  blk_write_time  time spent writing blocks (with track_io_timing)
  delay_time      time spent in vacuum cost-based delay
  total_time      total wall-clock time of the vacuum

  wraparound_failsafe  number of vacuums that engaged the wraparound
                       failsafe (tables expose it as a per-relation flag,
                       the database aggregate as a count)

total_time is always positive; the regression test also exercises the
positive delay_time path with a dedicated cost-delayed vacuum.  The positive
wraparound_failsafe path requires reaching the failsafe XID age and is covered
by a separate TAP test under src/test/modules/xid_wraparound.
---
 doc/src/sgml/system-views.sgml                | 112 ++++++++++++++++++
 src/backend/access/heap/vacuumlazy.c          |  28 +++++
 src/backend/catalog/system_views.sql          |  23 +++-
 src/backend/utils/activity/pgstat_vacuum.c    |   6 +
 src/backend/utils/adt/pgstatfuncs.c           |  23 +++-
 src/include/catalog/pg_proc.dat               |  18 +--
 src/include/pgstat.h                          |   9 ++
 src/test/modules/xid_wraparound/meson.build   |   1 +
 .../t/005_vacuum_stats_failsafe.pl            |  66 +++++++++++
 src/test/regress/expected/rules.out           |  26 +++-
 src/test/regress/expected/vacuum_stats.out    |  62 ++++++++--
 src/test/regress/sql/vacuum_stats.sql         |  42 ++++++-
 12 files changed, 385 insertions(+), 31 deletions(-)
 create mode 100644 src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 8e80db3e03..0443cbad40 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5964,6 +5964,46 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of this relation's blocks found in shared buffers by the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_read_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent reading blocks by the vacuum, in milliseconds (when <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_write_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent writing (flushing) blocks by the vacuum, in milliseconds; remains zero if no flushes occurred.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>delay_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Total time the vacuum spent sleeping in vacuum delay points, in milliseconds.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Total wall-clock time of the vacuum, in milliseconds, including time spent waiting for I/O and locks.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wraparound_failsafe</structfield> <type>integer</type>
+      </para>
+      <para>
+       Number of times the failsafe mechanism was triggered to prevent transaction ID wraparound during the vacuum.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6103,6 +6143,38 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of block hits within this index during the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_read_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent reading index blocks, in milliseconds (if <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_write_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent writing index blocks, in milliseconds (if <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>delay_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent in vacuum cost-based delay while processing this index, in milliseconds.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Total time spent vacuuming this index, in milliseconds.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6193,6 +6265,46 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of shared buffer blocks written out by vacuum operations in this database.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wraparound_failsafe</structfield> <type>integer</type>
+      </para>
+      <para>
+       Number of vacuum operations in this database that engaged the wraparound failsafe mechanism.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_read_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent reading blocks by vacuum operations in this database, in milliseconds (if <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_write_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent writing blocks by vacuum operations in this database, in milliseconds (if <varname>track_io_timing</varname> is enabled).
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>delay_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Time spent in vacuum cost-based delay by vacuum operations in this database, in milliseconds.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_time</structfield> <type>double precision</type>
+      </para>
+      <para>
+       Total time spent by vacuum operations in this database, in milliseconds.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index fdd70653e3..997eabf1ec 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -552,6 +552,9 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 				 PgStat_CommonCounts * report)
 {
 	BufferUsage bufusage;
+	TimestampTz endtime;
+	long		secs;
+	int			usecs;
 
 	if (!pgstat_track_vacuum_statistics)
 		return;
@@ -562,6 +565,9 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 	memset(&bufusage, 0, sizeof(BufferUsage));
 	BufferUsageAccumDiff(&bufusage, &pgBufferUsage, &counters->bufusage);
 
+	endtime = GetCurrentTimestamp();
+	TimestampDifference(counters->starttime, endtime, &secs, &usecs);
+
 	/*
 	 * Fill additional statistics on a vacuum processing operation.
 	 */
@@ -570,6 +576,14 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 	report->total_blks_dirtied += bufusage.local_blks_dirtied + bufusage.shared_blks_dirtied;
 	report->total_blks_written += bufusage.shared_blks_written;
 
+	report->blk_read_time += INSTR_TIME_GET_MILLISEC(bufusage.local_blk_read_time);
+	report->blk_read_time += INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_read_time);
+	report->blk_write_time += INSTR_TIME_GET_MILLISEC(bufusage.local_blk_write_time);
+	report->blk_write_time += INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_write_time);
+	report->delay_time += VacuumDelayTime - counters->VacuumDelayTime;
+
+	report->total_time += secs * 1000. + usecs / 1000.;
+
 	if (!rel->pgstat_info || !pgstat_track_counts)
 
 		/*
@@ -667,21 +681,31 @@ accumulate_heap_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCount
 	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
 	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
 	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
+	extVacStats->common.wraparound_failsafe_count = vacrel->wraparound_failsafe_count;
 
+	extVacStats->common.blk_read_time -= vacrel->extVacReportIdx.common.blk_read_time;
+	extVacStats->common.blk_write_time -= vacrel->extVacReportIdx.common.blk_write_time;
 	extVacStats->common.total_blks_dirtied -= vacrel->extVacReportIdx.common.total_blks_dirtied;
 	extVacStats->common.total_blks_hit -= vacrel->extVacReportIdx.common.total_blks_hit;
 	extVacStats->common.total_blks_read -= vacrel->extVacReportIdx.common.total_blks_read;
 	extVacStats->common.total_blks_written -= vacrel->extVacReportIdx.common.total_blks_written;
+
+	extVacStats->common.total_time -= vacrel->extVacReportIdx.common.total_time;
+	extVacStats->common.delay_time -= vacrel->extVacReportIdx.common.delay_time;
 }
 
 static void
 accumulate_idxs_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCounts * extVacIdxStats)
 {
 	/* Fill heap-specific extended stats fields */
+	vacrel->extVacReportIdx.common.blk_read_time += extVacIdxStats->common.blk_read_time;
+	vacrel->extVacReportIdx.common.blk_write_time += extVacIdxStats->common.blk_write_time;
 	vacrel->extVacReportIdx.common.total_blks_dirtied += extVacIdxStats->common.total_blks_dirtied;
 	vacrel->extVacReportIdx.common.total_blks_hit += extVacIdxStats->common.total_blks_hit;
 	vacrel->extVacReportIdx.common.total_blks_read += extVacIdxStats->common.total_blks_read;
 	vacrel->extVacReportIdx.common.total_blks_written += extVacIdxStats->common.total_blks_written;
+	vacrel->extVacReportIdx.common.delay_time += extVacIdxStats->common.delay_time;
+	vacrel->extVacReportIdx.common.total_time += extVacIdxStats->common.total_time;
 }
 
 /*
@@ -706,6 +730,10 @@ extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
 	dst->common.total_blks_written += src->common.total_blks_written;
 	dst->common.blks_fetched += src->common.blks_fetched;
 	dst->common.blks_hit += src->common.blks_hit;
+	dst->common.blk_read_time += src->common.blk_read_time;
+	dst->common.blk_write_time += src->common.blk_write_time;
+	dst->common.delay_time += src->common.delay_time;
+	dst->common.total_time += src->common.total_time;
 	dst->common.tuples_deleted += src->common.tuples_deleted;
 
 	dst->index.pages_deleted += src->index.pages_deleted;
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index e7d4165844..11135733c2 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1581,7 +1581,12 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.total_blks_dirtied AS total_blks_dirtied,
         S.total_blks_written AS total_blks_written,
         S.rel_blks_read AS rel_blks_read,
-        S.rel_blks_hit AS rel_blks_hit
+        S.rel_blks_hit AS rel_blks_hit,
+        S.blk_read_time AS blk_read_time,
+        S.blk_write_time AS blk_write_time,
+        S.delay_time AS delay_time,
+        S.total_time AS total_time,
+        S.wraparound_failsafe AS wraparound_failsafe
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
@@ -1605,7 +1610,12 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             S.total_blks_written AS total_blks_written,
 
             S.rel_blks_read AS rel_blks_read,
-            S.rel_blks_hit AS rel_blks_hit
+            S.rel_blks_hit AS rel_blks_hit,
+
+            S.blk_read_time AS blk_read_time,
+            S.blk_write_time AS blk_write_time,
+            S.delay_time AS delay_time,
+            S.total_time AS total_time
     FROM
             pg_class C JOIN
             pg_index X ON C.oid = X.indrelid JOIN
@@ -1624,7 +1634,14 @@ CREATE VIEW pg_stat_vacuum_database AS
             S.db_blks_read AS db_blks_read,
             S.db_blks_hit AS db_blks_hit,
             S.total_blks_dirtied AS total_blks_dirtied,
-            S.total_blks_written AS total_blks_written
+            S.total_blks_written AS total_blks_written,
+
+            S.wraparound_failsafe AS wraparound_failsafe,
+
+            S.blk_read_time AS blk_read_time,
+            S.blk_write_time AS blk_write_time,
+            S.delay_time AS delay_time,
+            S.total_time AS total_time
     FROM
             pg_database D,
             LATERAL pg_stat_get_vacuum_database(D.oid) S;
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 9b1eaf0f4a..d0e2eea258 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -46,7 +46,13 @@ pgstat_accumulate_common(PgStat_CommonCounts *dst, const PgStat_CommonCounts *sr
 	ACCUMULATE_FIELD(blks_fetched);
 	ACCUMULATE_FIELD(blks_hit);
 
+	ACCUMULATE_FIELD(blk_read_time);
+	ACCUMULATE_FIELD(blk_write_time);
+	ACCUMULATE_FIELD(delay_time);
+	ACCUMULATE_FIELD(total_time);
+
 	ACCUMULATE_FIELD(tuples_deleted);
+	ACCUMULATE_FIELD(wraparound_failsafe_count);
 }
 
 /*
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 4f0be66621..75ebff6d68 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 17
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 22
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2416,6 +2416,11 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
 	values[i++] = Int64GetDatum(extvacuum->common.blks_fetched - extvacuum->common.blks_hit);
 	values[i++] = Int64GetDatum(extvacuum->common.blks_hit);
+	values[i++] = Float8GetDatum(extvacuum->common.blk_read_time);
+	values[i++] = Float8GetDatum(extvacuum->common.blk_write_time);
+	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
+	values[i++] = Float8GetDatum(extvacuum->common.total_time);
+	values[i++] = Int32GetDatum(extvacuum->common.wraparound_failsafe_count);
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
@@ -2429,7 +2434,7 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 9
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 13
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2465,6 +2470,11 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.blks_fetched - extvacuum->common.blks_hit);
 	values[i++] = Int64GetDatum(extvacuum->common.blks_hit);
 
+	values[i++] = Float8GetDatum(extvacuum->common.blk_read_time);
+	values[i++] = Float8GetDatum(extvacuum->common.blk_write_time);
+	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
+	values[i++] = Float8GetDatum(extvacuum->common.total_time);
+
 	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
 
 	/* Returns the record as Datum */
@@ -2477,7 +2487,7 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 6
+#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 11
 
 	Oid			dbid = PG_GETARG_OID(0);
 	PgStat_VacuumDBCounts *extvacuum;
@@ -2505,6 +2515,13 @@ pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_dirtied);
 	values[i++] = Int64GetDatum(extvacuum->common.total_blks_written);
 
+	values[i++] = Int32GetDatum(extvacuum->common.wraparound_failsafe_count);
+
+	values[i++] = Float8GetDatum(extvacuum->common.blk_read_time);
+	values[i++] = Float8GetDatum(extvacuum->common.blk_write_time);
+	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
+	values[i++] = Float8GetDatum(extvacuum->common.total_time);
+
 	Assert(i == PG_STAT_GET_VACUUM_DATABASE_STATS_COLS);
 
 	/* Returns the record as Datum */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 14f070a941..75d6e2b329 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8,int4}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time,wraparound_failsafe}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
@@ -12718,17 +12718,17 @@
   proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time}',
   prosrc => 'pg_stat_get_vacuum_indexes' },
 { oid => '8005',
   descr => 'pg_stat_get_vacuum_database returns vacuum stats values for database',
   proname => 'pg_stat_get_vacuum_database', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8}',
-  proargmodes => '{i,o,o,o,o,o,o}',
-  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written}',
+  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8,int4,float8,float8,float8,float8}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written,wraparound_failsafe,blk_read_time,blk_write_time,delay_time,total_time}',
   prosrc => 'pg_stat_get_vacuum_database' },
 ]
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index de38949d12..bfc2995389 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -185,8 +185,17 @@ typedef struct PgStat_CommonCounts
 	int64		blks_fetched;
 	int64		blks_hit;
 
+	/* Time */
+	double		blk_read_time;
+	double		blk_write_time;
+	double		delay_time;
+	double		total_time;
+
 	/* tuples */
 	int64		tuples_deleted;
+
+	/* failsafe */
+	int32		wraparound_failsafe_count;
 }			PgStat_CommonCounts;
 
 /* ----------
diff --git a/src/test/modules/xid_wraparound/meson.build b/src/test/modules/xid_wraparound/meson.build
index 97ce670f9a..9224e59d1d 100644
--- a/src/test/modules/xid_wraparound/meson.build
+++ b/src/test/modules/xid_wraparound/meson.build
@@ -31,6 +31,7 @@ tests += {
       't/002_limits.pl',
       't/003_wraparounds.pl',
       't/004_notify_freeze.pl',
+      't/005_vacuum_stats_failsafe.pl',
     ],
   },
 }
diff --git a/src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl b/src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl
new file mode 100644
index 0000000000..eff77fe876
--- /dev/null
+++ b/src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl
@@ -0,0 +1,66 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+
+# Test that the wraparound failsafe counter exposed by the extended vacuum
+# statistics views advances when a VACUUM engages the wraparound failsafe.
+#
+# The failsafe only triggers once a relation's age exceeds
+# max(vacuum_failsafe_age, autovacuum_freeze_max_age * 1.05), which cannot be
+# reached by an ordinary regression test.  Here we lower
+# autovacuum_freeze_max_age to its minimum and use the xid_wraparound
+# extension to burn enough transaction IDs to age the table past that
+# threshold, then check that the wraparound_failsafe counters advance.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bxid_wraparound\b/)
+{
+	plan skip_all => "test xid_wraparound not enabled in PG_TEST_EXTRA";
+}
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf(
+	'postgresql.conf', qq[
+autovacuum = off
+track_vacuum_statistics = on
+# Lower the wraparound-failsafe threshold as far as possible so that a modest
+# number of consumed XIDs is enough to engage the failsafe.
+autovacuum_freeze_max_age = 100000
+vacuum_failsafe_age = 0
+]);
+$node->start;
+$node->safe_psql('postgres', 'CREATE EXTENSION xid_wraparound');
+
+# A table whose relfrozenxid age we will push past the failsafe threshold.
+$node->safe_psql(
+	'postgres', qq[
+CREATE TABLE fs_tab (id int) WITH (autovacuum_enabled = off);
+INSERT INTO fs_tab SELECT generate_series(1, 1000);
+]);
+
+# Advance the XID counter well past the failsafe threshold.
+$node->safe_psql('postgres', 'SELECT consume_xids(200000)');
+
+# This VACUUM must engage the wraparound failsafe.
+$node->safe_psql('postgres', 'VACUUM fs_tab');
+
+# The per-table view records that the failsafe was engaged for this relation.
+my $tab = $node->safe_psql(
+	'postgres', qq[
+SELECT wraparound_failsafe > 0
+  FROM pg_stat_vacuum_tables WHERE relname = 'fs_tab']);
+is($tab, 't', 'wraparound_failsafe advanced in pg_stat_vacuum_tables');
+
+# The per-database aggregate counts the failsafe as well.
+my $db = $node->safe_psql(
+	'postgres', qq[
+SELECT wraparound_failsafe > 0
+  FROM pg_stat_vacuum_database WHERE dbname = current_database()]);
+is($db, 't', 'wraparound_failsafe advanced in pg_stat_vacuum_database');
+
+$node->stop;
+done_testing();
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 0b28e2ffc3..952392f66d 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2427,9 +2427,14 @@ pg_stat_vacuum_database| SELECT d.oid AS dboid,
     s.db_blks_read,
     s.db_blks_hit,
     s.total_blks_dirtied,
-    s.total_blks_written
+    s.total_blks_written,
+    s.wraparound_failsafe,
+    s.blk_read_time,
+    s.blk_write_time,
+    s.delay_time,
+    s.total_time
    FROM pg_database d,
-    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written);
+    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written, wraparound_failsafe, blk_read_time, blk_write_time, delay_time, total_time);
 pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     i.oid AS indexrelid,
     n.nspname AS schemaname,
@@ -2442,12 +2447,16 @@ pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     s.total_blks_dirtied,
     s.total_blks_written,
     s.rel_blks_read,
-    s.rel_blks_hit
+    s.rel_blks_hit,
+    s.blk_read_time,
+    s.blk_write_time,
+    s.delay_time,
+    s.total_time
    FROM (((pg_class c
      JOIN pg_index x ON ((c.oid = x.indrelid)))
      JOIN pg_class i ON ((i.oid = x.indexrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit)
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     c.relname,
@@ -2467,10 +2476,15 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.total_blks_dirtied,
     s.total_blks_written,
     s.rel_blks_read,
-    s.rel_blks_hit
+    s.rel_blks_hit,
+    s.blk_read_time,
+    s.blk_write_time,
+    s.delay_time,
+    s.total_time,
+    s.wraparound_failsafe
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time, wraparound_failsafe)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index bf2fbc7626..b5bae99946 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -124,6 +124,43 @@ SELECT rel_blks_read >= 0 AS rel_blks_read,
  t             | t
 (1 row)
 
+-- timing metrics and failsafe.  The vacuum always takes some wall-clock time
+-- (total_time > 0) and does not engage the wraparound failsafe under normal
+-- conditions (wraparound_failsafe = 0).  blk_read_time/blk_write_time are only
+-- non-zero when track_io_timing is enabled, and delay_time only when a vacuum
+-- cost delay is configured, so those are merely checked for being
+-- non-negative; the positive delay_time path is exercised separately below.
+SELECT blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time,
+       wraparound_failsafe = 0 AS wraparound_failsafe
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ blk_read_time | blk_write_time | delay_time | total_time | wraparound_failsafe 
+---------------+----------------+------------+------------+---------------------
+ t             | t              | t          | t          | t
+(1 row)
+
+-- delay path: with a vacuum cost delay configured, a vacuum that accrues cost
+-- sleeps, so delay_time advances.
+CREATE TABLE vacstat_delay (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off, fillfactor = 10);
+INSERT INTO vacstat_delay SELECT g, repeat('x', 100) FROM generate_series(1, 3000) g;
+DELETE FROM vacstat_delay WHERE id % 2 = 0;
+SET vacuum_cost_delay = '1ms';
+SET vacuum_cost_limit = 1;
+VACUUM vacstat_delay;
+RESET vacuum_cost_delay;
+RESET vacuum_cost_limit;
+SELECT delay_time > 0 AS delay_time,
+       total_time > 0 AS total_time
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_delay';
+ delay_time | total_time 
+------------+------------
+ t          | t
+(1 row)
+
+DROP TABLE vacstat_delay;
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -137,11 +174,15 @@ SELECT indexrelname,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
        total_blks_written >= 0 AS total_blks_written,
        rel_blks_read >= 0 AS rel_blks_read,
-       rel_blks_hit > 0 AS rel_blks_hit
+       rel_blks_hit > 0 AS rel_blks_hit,
+       blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
-  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit 
-----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------
- vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t
+  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit | blk_read_time | blk_write_time | delay_time | total_time 
+----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------+---------------+----------------+------------+------------
+ vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t            | t             | t              | t          | t
 (1 row)
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -170,10 +211,15 @@ SELECT errors = 0 AS errors,
        db_blks_read >= 0 AS db_blks_read,
        db_blks_hit > 0 AS db_blks_hit,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
-       total_blks_written >= 0 AS total_blks_written
+       total_blks_written >= 0 AS total_blks_written,
+       wraparound_failsafe = 0 AS wraparound_failsafe,
+       blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time
   FROM pg_stat_vacuum_database WHERE dbname = current_database();
- errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written 
---------+--------------+-------------+--------------------+--------------------
- t      | t            | t           | t                  | t
+ errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written | wraparound_failsafe | blk_read_time | blk_write_time | delay_time | total_time 
+--------+--------------+-------------+--------------------+--------------------+---------------------+---------------+----------------+------------+------------
+ t      | t            | t           | t                  | t                  | t                   | t             | t              | t          | t
 (1 row)
 
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index 49ed3b4063..ee517d6f44 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -94,6 +94,35 @@ SELECT rel_blks_read >= 0 AS rel_blks_read,
        rel_blks_hit > 0 AS rel_blks_hit
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
 
+-- timing metrics and failsafe.  The vacuum always takes some wall-clock time
+-- (total_time > 0) and does not engage the wraparound failsafe under normal
+-- conditions (wraparound_failsafe = 0).  blk_read_time/blk_write_time are only
+-- non-zero when track_io_timing is enabled, and delay_time only when a vacuum
+-- cost delay is configured, so those are merely checked for being
+-- non-negative; the positive delay_time path is exercised separately below.
+SELECT blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time,
+       wraparound_failsafe = 0 AS wraparound_failsafe
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
+-- delay path: with a vacuum cost delay configured, a vacuum that accrues cost
+-- sleeps, so delay_time advances.
+CREATE TABLE vacstat_delay (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off, fillfactor = 10);
+INSERT INTO vacstat_delay SELECT g, repeat('x', 100) FROM generate_series(1, 3000) g;
+DELETE FROM vacstat_delay WHERE id % 2 = 0;
+SET vacuum_cost_delay = '1ms';
+SET vacuum_cost_limit = 1;
+VACUUM vacstat_delay;
+RESET vacuum_cost_delay;
+RESET vacuum_cost_limit;
+SELECT delay_time > 0 AS delay_time,
+       total_time > 0 AS total_time
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_delay';
+DROP TABLE vacstat_delay;
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -107,7 +136,11 @@ SELECT indexrelname,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
        total_blks_written >= 0 AS total_blks_written,
        rel_blks_read >= 0 AS rel_blks_read,
-       rel_blks_hit > 0 AS rel_blks_hit
+       rel_blks_hit > 0 AS rel_blks_hit,
+       blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -132,5 +165,10 @@ SELECT errors = 0 AS errors,
        db_blks_read >= 0 AS db_blks_read,
        db_blks_hit > 0 AS db_blks_hit,
        total_blks_dirtied >= 0 AS total_blks_dirtied,
-       total_blks_written >= 0 AS total_blks_written
+       total_blks_written >= 0 AS total_blks_written,
+       wraparound_failsafe = 0 AS wraparound_failsafe,
+       blk_read_time >= 0 AS blk_read_time,
+       blk_write_time >= 0 AS blk_write_time,
+       delay_time >= 0 AS delay_time,
+       total_time > 0 AS total_time
   FROM pg_stat_vacuum_database WHERE dbname = current_database();
-- 
2.39.5 (Apple Git-154)



  [text/plain] 0008-Extended-vacuum-statistics-WAL-metrics-for-tables-in.patch (29.6K, 10-0008-Extended-vacuum-statistics-WAL-metrics-for-tables-in.patch)
  download | inline diff:
From 868f4512b0f73f8bb1869e7be33ef0d6035332b3 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 16 Jun 2026 10:53:49 +0300
Subject: [PATCH 8/8] Extended vacuum statistics: WAL metrics for tables,
 indexes and database

Expose the WAL generation counters in pg_stat_vacuum_tables,
pg_stat_vacuum_indexes and pg_stat_vacuum_database, with documentation and
regression coverage:

  wal_records  number of WAL records generated by the vacuum
  wal_fpi      number of WAL full page images generated by the vacuum
  wal_bytes    total amount of WAL generated by the vacuum, in bytes

A vacuum that removes tuples always emits WAL, so wal_records and wal_bytes
are positive.  wal_fpi depends on checkpoint timing; the regression test
exercises its positive path with a dedicated vacuum run right after a
CHECKPOINT.

This completes the extended vacuum statistics views: every metric category is
now reported for tables, indexes and the database aggregate.
---
 doc/src/sgml/system-views.sgml             | 72 ++++++++++++++++++++++
 src/backend/access/heap/vacuumlazy.c       | 19 +++++-
 src/backend/catalog/system_views.sql       | 17 ++++-
 src/backend/utils/activity/pgstat_vacuum.c |  4 ++
 src/backend/utils/adt/pgstatfuncs.c        | 32 +++++++++-
 src/include/catalog/pg_proc.dat            | 18 +++---
 src/include/pgstat.h                       |  5 ++
 src/test/regress/expected/rules.out        | 21 +++++--
 src/test/regress/expected/vacuum_stats.out | 54 +++++++++++++---
 src/test/regress/sql/vacuum_stats.sql      | 34 +++++++++-
 10 files changed, 244 insertions(+), 32 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 0443cbad40..50fcc3aa7d 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -6004,6 +6004,30 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of times the failsafe mechanism was triggered to prevent transaction ID wraparound during the vacuum.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_records</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Total number of WAL records generated by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_fpi</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Total number of WAL full page images generated by the vacuum.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_bytes</structfield> <type>numeric</type>
+      </para>
+      <para>
+       Total amount of WAL generated by the vacuum, in bytes.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6175,6 +6199,30 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Total time spent vacuuming this index, in milliseconds.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_records</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of WAL records generated while vacuuming this index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_fpi</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of WAL full page images generated while vacuuming this index.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_bytes</structfield> <type>numeric</type>
+      </para>
+      <para>
+       Total amount of WAL generated while vacuuming this index, in bytes.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -6305,6 +6353,30 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Total time spent by vacuum operations in this database, in milliseconds.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_records</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of WAL records generated by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_fpi</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of WAL full page images generated by vacuum operations in this database.
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_bytes</structfield> <type>numeric</type>
+      </para>
+      <para>
+       Total amount of WAL generated by vacuum operations in this database, in bytes.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 997eabf1ec..8c2e595a13 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -551,6 +551,7 @@ static void
 extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 				 PgStat_CommonCounts * report)
 {
+	WalUsage	walusage;
 	BufferUsage bufusage;
 	TimestampTz endtime;
 	long		secs;
@@ -561,7 +562,10 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 
 	memset(report, 0, sizeof(PgStat_CommonCounts));
 
-	/* Calculate diffs of global stat parameters on buffer usage. */
+	/* Calculate diffs of global stat parameters on WAL and buffer usage. */
+	memset(&walusage, 0, sizeof(WalUsage));
+	WalUsageAccumDiff(&walusage, &pgWalUsage, &counters->walusage);
+
 	memset(&bufusage, 0, sizeof(BufferUsage));
 	BufferUsageAccumDiff(&bufusage, &pgBufferUsage, &counters->bufusage);
 
@@ -576,6 +580,10 @@ extvac_stats_end(Relation rel, LVExtStatCounters * counters,
 	report->total_blks_dirtied += bufusage.local_blks_dirtied + bufusage.shared_blks_dirtied;
 	report->total_blks_written += bufusage.shared_blks_written;
 
+	report->wal_records += walusage.wal_records;
+	report->wal_fpi += walusage.wal_fpi;
+	report->wal_bytes += walusage.wal_bytes;
+
 	report->blk_read_time += INSTR_TIME_GET_MILLISEC(bufusage.local_blk_read_time);
 	report->blk_read_time += INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_read_time);
 	report->blk_write_time += INSTR_TIME_GET_MILLISEC(bufusage.local_blk_write_time);
@@ -689,6 +697,9 @@ accumulate_heap_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCount
 	extVacStats->common.total_blks_hit -= vacrel->extVacReportIdx.common.total_blks_hit;
 	extVacStats->common.total_blks_read -= vacrel->extVacReportIdx.common.total_blks_read;
 	extVacStats->common.total_blks_written -= vacrel->extVacReportIdx.common.total_blks_written;
+	extVacStats->common.wal_bytes -= vacrel->extVacReportIdx.common.wal_bytes;
+	extVacStats->common.wal_fpi -= vacrel->extVacReportIdx.common.wal_fpi;
+	extVacStats->common.wal_records -= vacrel->extVacReportIdx.common.wal_records;
 
 	extVacStats->common.total_time -= vacrel->extVacReportIdx.common.total_time;
 	extVacStats->common.delay_time -= vacrel->extVacReportIdx.common.delay_time;
@@ -704,6 +715,9 @@ accumulate_idxs_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCount
 	vacrel->extVacReportIdx.common.total_blks_hit += extVacIdxStats->common.total_blks_hit;
 	vacrel->extVacReportIdx.common.total_blks_read += extVacIdxStats->common.total_blks_read;
 	vacrel->extVacReportIdx.common.total_blks_written += extVacIdxStats->common.total_blks_written;
+	vacrel->extVacReportIdx.common.wal_bytes += extVacIdxStats->common.wal_bytes;
+	vacrel->extVacReportIdx.common.wal_fpi += extVacIdxStats->common.wal_fpi;
+	vacrel->extVacReportIdx.common.wal_records += extVacIdxStats->common.wal_records;
 	vacrel->extVacReportIdx.common.delay_time += extVacIdxStats->common.delay_time;
 	vacrel->extVacReportIdx.common.total_time += extVacIdxStats->common.total_time;
 }
@@ -730,6 +744,9 @@ extvac_accumulate_idx_report(PgStat_VacuumRelationCounts * dst,
 	dst->common.total_blks_written += src->common.total_blks_written;
 	dst->common.blks_fetched += src->common.blks_fetched;
 	dst->common.blks_hit += src->common.blks_hit;
+	dst->common.wal_records += src->common.wal_records;
+	dst->common.wal_fpi += src->common.wal_fpi;
+	dst->common.wal_bytes += src->common.wal_bytes;
 	dst->common.blk_read_time += src->common.blk_read_time;
 	dst->common.blk_write_time += src->common.blk_write_time;
 	dst->common.delay_time += src->common.delay_time;
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 11135733c2..31c97120b6 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1586,7 +1586,10 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.blk_write_time AS blk_write_time,
         S.delay_time AS delay_time,
         S.total_time AS total_time,
-        S.wraparound_failsafe AS wraparound_failsafe
+        S.wraparound_failsafe AS wraparound_failsafe,
+        S.wal_records AS wal_records,
+        S.wal_fpi AS wal_fpi,
+        S.wal_bytes AS wal_bytes
 
     FROM pg_class C JOIN
             pg_namespace N ON N.oid = C.relnamespace,
@@ -1615,7 +1618,11 @@ CREATE VIEW pg_stat_vacuum_indexes AS
             S.blk_read_time AS blk_read_time,
             S.blk_write_time AS blk_write_time,
             S.delay_time AS delay_time,
-            S.total_time AS total_time
+            S.total_time AS total_time,
+
+            S.wal_records AS wal_records,
+            S.wal_fpi AS wal_fpi,
+            S.wal_bytes AS wal_bytes
     FROM
             pg_class C JOIN
             pg_index X ON C.oid = X.indrelid JOIN
@@ -1641,7 +1648,11 @@ CREATE VIEW pg_stat_vacuum_database AS
             S.blk_read_time AS blk_read_time,
             S.blk_write_time AS blk_write_time,
             S.delay_time AS delay_time,
-            S.total_time AS total_time
+            S.total_time AS total_time,
+
+            S.wal_records AS wal_records,
+            S.wal_fpi AS wal_fpi,
+            S.wal_bytes AS wal_bytes
     FROM
             pg_database D,
             LATERAL pg_stat_get_vacuum_database(D.oid) S;
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index d0e2eea258..c0a6bf9e97 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -46,6 +46,10 @@ pgstat_accumulate_common(PgStat_CommonCounts *dst, const PgStat_CommonCounts *sr
 	ACCUMULATE_FIELD(blks_fetched);
 	ACCUMULATE_FIELD(blks_hit);
 
+	ACCUMULATE_FIELD(wal_records);
+	ACCUMULATE_FIELD(wal_fpi);
+	ACCUMULATE_FIELD(wal_bytes);
+
 	ACCUMULATE_FIELD(blk_read_time);
 	ACCUMULATE_FIELD(blk_write_time);
 	ACCUMULATE_FIELD(delay_time);
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index 75ebff6d68..a03b7ef17b 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,13 +2374,14 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 22
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 25
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
 	TupleDesc	tupdesc;
 	Datum		values[PG_STAT_GET_VACUUM_TABLES_STATS_COLS] = {0};
 	bool		nulls[PG_STAT_GET_VACUUM_TABLES_STATS_COLS] = {0};
+	char		buf[256];
 	int			i = 0;
 
 	/* Build a tuple descriptor for our result type */
@@ -2421,6 +2422,13 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
 	values[i++] = Float8GetDatum(extvacuum->common.total_time);
 	values[i++] = Int32GetDatum(extvacuum->common.wraparound_failsafe_count);
+	values[i++] = Int64GetDatum(extvacuum->common.wal_records);
+	values[i++] = Int64GetDatum(extvacuum->common.wal_fpi);
+	snprintf(buf, sizeof buf, UINT64_FORMAT, extvacuum->common.wal_bytes);
+	values[i++] = DirectFunctionCall3(numeric_in,
+									  CStringGetDatum(buf),
+									  ObjectIdGetDatum(0),
+									  Int32GetDatum(-1));
 
 	Assert(i == PG_STAT_GET_VACUUM_TABLES_STATS_COLS);
 
@@ -2434,13 +2442,14 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 13
+#define PG_STAT_GET_VACUUM_INDEX_STATS_COLS 16
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
 	TupleDesc	tupdesc;
 	Datum		values[PG_STAT_GET_VACUUM_INDEX_STATS_COLS] = {0};
 	bool		nulls[PG_STAT_GET_VACUUM_INDEX_STATS_COLS] = {0};
+	char		buf[256];
 	int			i = 0;
 
 	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
@@ -2475,6 +2484,14 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
 	values[i++] = Float8GetDatum(extvacuum->common.total_time);
 
+	values[i++] = Int64GetDatum(extvacuum->common.wal_records);
+	values[i++] = Int64GetDatum(extvacuum->common.wal_fpi);
+	snprintf(buf, sizeof buf, UINT64_FORMAT, extvacuum->common.wal_bytes);
+	values[i++] = DirectFunctionCall3(numeric_in,
+									  CStringGetDatum(buf),
+									  ObjectIdGetDatum(0),
+									  Int32GetDatum(-1));
+
 	Assert(i == PG_STAT_GET_VACUUM_INDEX_STATS_COLS);
 
 	/* Returns the record as Datum */
@@ -2487,13 +2504,14 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 11
+#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 14
 
 	Oid			dbid = PG_GETARG_OID(0);
 	PgStat_VacuumDBCounts *extvacuum;
 	TupleDesc	tupdesc;
 	Datum		values[PG_STAT_GET_VACUUM_DATABASE_STATS_COLS] = {0};
 	bool		nulls[PG_STAT_GET_VACUUM_DATABASE_STATS_COLS] = {0};
+	char		buf[256];
 	int			i = 0;
 
 	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
@@ -2522,6 +2540,14 @@ pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 	values[i++] = Float8GetDatum(extvacuum->common.delay_time);
 	values[i++] = Float8GetDatum(extvacuum->common.total_time);
 
+	values[i++] = Int64GetDatum(extvacuum->common.wal_records);
+	values[i++] = Int64GetDatum(extvacuum->common.wal_fpi);
+	snprintf(buf, sizeof buf, UINT64_FORMAT, extvacuum->common.wal_bytes);
+	values[i++] = DirectFunctionCall3(numeric_in,
+									  CStringGetDatum(buf),
+									  ObjectIdGetDatum(0),
+									  Int32GetDatum(-1));
+
 	Assert(i == PG_STAT_GET_VACUUM_DATABASE_STATS_COLS);
 
 	/* Returns the record as Datum */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 75d6e2b329..4e64f42042 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8,int4}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time,wraparound_failsafe}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8,int4,int8,int8,numeric}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,vm_new_frozen_pages,vm_new_visible_pages,vm_new_visible_frozen_pages,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time,wraparound_failsafe,wal_records,wal_fpi,wal_bytes}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
@@ -12718,17 +12718,17 @@
   proname => 'pg_stat_get_vacuum_indexes', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,float8,float8,float8,float8,int8,int8,numeric}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_deleted,tuples_deleted,total_blks_read,total_blks_hit,total_blks_dirtied,total_blks_written,rel_blks_read,rel_blks_hit,blk_read_time,blk_write_time,delay_time,total_time,wal_records,wal_fpi,wal_bytes}',
   prosrc => 'pg_stat_get_vacuum_indexes' },
 { oid => '8005',
   descr => 'pg_stat_get_vacuum_database returns vacuum stats values for database',
   proname => 'pg_stat_get_vacuum_database', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8,int4,float8,float8,float8,float8}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written,wraparound_failsafe,blk_read_time,blk_write_time,delay_time,total_time}',
+  proallargtypes => '{oid,oid,int4,int8,int8,int8,int8,int4,float8,float8,float8,float8,int8,int8,numeric}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{dbid,dboid,errors,db_blks_read,db_blks_hit,total_blks_dirtied,total_blks_written,wraparound_failsafe,blk_read_time,blk_write_time,delay_time,total_time,wal_records,wal_fpi,wal_bytes}',
   prosrc => 'pg_stat_get_vacuum_database' },
 ]
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index bfc2995389..88f2b027af 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -185,6 +185,11 @@ typedef struct PgStat_CommonCounts
 	int64		blks_fetched;
 	int64		blks_hit;
 
+	/* WAL */
+	int64		wal_records;
+	int64		wal_fpi;
+	uint64		wal_bytes;
+
 	/* Time */
 	double		blk_read_time;
 	double		blk_write_time;
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 952392f66d..d0d2135c06 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2432,9 +2432,12 @@ pg_stat_vacuum_database| SELECT d.oid AS dboid,
     s.blk_read_time,
     s.blk_write_time,
     s.delay_time,
-    s.total_time
+    s.total_time,
+    s.wal_records,
+    s.wal_fpi,
+    s.wal_bytes
    FROM pg_database d,
-    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written, wraparound_failsafe, blk_read_time, blk_write_time, delay_time, total_time);
+    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, db_blks_read, db_blks_hit, total_blks_dirtied, total_blks_written, wraparound_failsafe, blk_read_time, blk_write_time, delay_time, total_time, wal_records, wal_fpi, wal_bytes);
 pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     i.oid AS indexrelid,
     n.nspname AS schemaname,
@@ -2451,12 +2454,15 @@ pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     s.blk_read_time,
     s.blk_write_time,
     s.delay_time,
-    s.total_time
+    s.total_time,
+    s.wal_records,
+    s.wal_fpi,
+    s.wal_bytes
    FROM (((pg_class c
      JOIN pg_index x ON ((c.oid = x.indrelid)))
      JOIN pg_class i ON ((i.oid = x.indexrelid)))
      LEFT JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time)
+    LATERAL pg_stat_get_vacuum_indexes(i.oid) s(relid, pages_deleted, tuples_deleted, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time, wal_records, wal_fpi, wal_bytes)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     c.relname,
@@ -2481,10 +2487,13 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.blk_write_time,
     s.delay_time,
     s.total_time,
-    s.wraparound_failsafe
+    s.wraparound_failsafe,
+    s.wal_records,
+    s.wal_fpi,
+    s.wal_bytes
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time, wraparound_failsafe)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, vm_new_frozen_pages, vm_new_visible_pages, vm_new_visible_frozen_pages, total_blks_read, total_blks_hit, total_blks_dirtied, total_blks_written, rel_blks_read, rel_blks_hit, blk_read_time, blk_write_time, delay_time, total_time, wraparound_failsafe, wal_records, wal_fpi, wal_bytes)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index b5bae99946..30fadd1e6a 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -161,6 +161,38 @@ SELECT delay_time > 0 AS delay_time,
 (1 row)
 
 DROP TABLE vacstat_delay;
+-- WAL metrics.  A vacuum that removes tuples always emits WAL
+-- (wal_records > 0, wal_bytes > 0).  wal_fpi depends on whether a checkpoint
+-- happened recently, so it is only checked for being non-negative here; the
+-- positive wal_fpi path is exercised separately below.
+SELECT wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ wal_records | wal_fpi | wal_bytes 
+-------------+---------+-----------
+ t           | t       | t
+(1 row)
+
+-- WAL full-page-image path: a CHECKPOINT immediately before the vacuum forces
+-- the first modification of each page to emit a full page image, so wal_fpi
+-- advances.
+CREATE TABLE vacstat_fpi (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_fpi SELECT g, repeat('x', 20) FROM generate_series(1, 1000) g;
+DELETE FROM vacstat_fpi WHERE id % 2 = 0;
+CHECKPOINT;
+VACUUM vacstat_fpi;
+SELECT wal_records > 0 AS wal_records,
+       wal_fpi > 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_fpi';
+ wal_records | wal_fpi | wal_bytes 
+-------------+---------+-----------
+ t           | t       | t
+(1 row)
+
+DROP TABLE vacstat_fpi;
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -178,11 +210,14 @@ SELECT indexrelname,
        blk_read_time >= 0 AS blk_read_time,
        blk_write_time >= 0 AS blk_write_time,
        delay_time >= 0 AS delay_time,
-       total_time > 0 AS total_time
+       total_time > 0 AS total_time,
+       wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
-  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit | blk_read_time | blk_write_time | delay_time | total_time 
-----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------+---------------+----------------+------------+------------
- vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t            | t             | t              | t          | t
+  indexrelname  | pages_deleted | tuples_deleted | total_blks_read | total_blks_hit | total_blks_dirtied | total_blks_written | rel_blks_read | rel_blks_hit | blk_read_time | blk_write_time | delay_time | total_time | wal_records | wal_fpi | wal_bytes 
+----------------+---------------+----------------+-----------------+----------------+--------------------+--------------------+---------------+--------------+---------------+----------------+------------+------------+-------------+---------+-----------
+ vacstat_t_pkey | t             | t              | t               | t              | t                  | t                  | t             | t            | t             | t              | t          | t          | t           | t       | t
 (1 row)
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -216,10 +251,13 @@ SELECT errors = 0 AS errors,
        blk_read_time >= 0 AS blk_read_time,
        blk_write_time >= 0 AS blk_write_time,
        delay_time >= 0 AS delay_time,
-       total_time > 0 AS total_time
+       total_time > 0 AS total_time,
+       wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
   FROM pg_stat_vacuum_database WHERE dbname = current_database();
- errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written | wraparound_failsafe | blk_read_time | blk_write_time | delay_time | total_time 
---------+--------------+-------------+--------------------+--------------------+---------------------+---------------+----------------+------------+------------
- t      | t            | t           | t                  | t                  | t                   | t             | t              | t          | t
+ errors | db_blks_read | db_blks_hit | total_blks_dirtied | total_blks_written | wraparound_failsafe | blk_read_time | blk_write_time | delay_time | total_time | wal_records | wal_fpi | wal_bytes 
+--------+--------------+-------------+--------------------+--------------------+---------------------+---------------+----------------+------------+------------+-------------+---------+-----------
+ t      | t            | t           | t                  | t                  | t                   | t             | t              | t          | t          | t           | t       | t
 (1 row)
 
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index ee517d6f44..2a366ebdf7 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -123,6 +123,30 @@ SELECT delay_time > 0 AS delay_time,
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_delay';
 DROP TABLE vacstat_delay;
 
+-- WAL metrics.  A vacuum that removes tuples always emits WAL
+-- (wal_records > 0, wal_bytes > 0).  wal_fpi depends on whether a checkpoint
+-- happened recently, so it is only checked for being non-negative here; the
+-- positive wal_fpi path is exercised separately below.
+SELECT wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
+-- WAL full-page-image path: a CHECKPOINT immediately before the vacuum forces
+-- the first modification of each page to emit a full page image, so wal_fpi
+-- advances.
+CREATE TABLE vacstat_fpi (id int PRIMARY KEY, v text)
+  WITH (autovacuum_enabled = off);
+INSERT INTO vacstat_fpi SELECT g, repeat('x', 20) FROM generate_series(1, 1000) g;
+DELETE FROM vacstat_fpi WHERE id % 2 = 0;
+CHECKPOINT;
+VACUUM vacstat_fpi;
+SELECT wal_records > 0 AS wal_records,
+       wal_fpi > 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_fpi';
+DROP TABLE vacstat_fpi;
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.  The index is
@@ -140,7 +164,10 @@ SELECT indexrelname,
        blk_read_time >= 0 AS blk_read_time,
        blk_write_time >= 0 AS blk_write_time,
        delay_time >= 0 AS delay_time,
-       total_time > 0 AS total_time
+       total_time > 0 AS total_time,
+       wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
   FROM pg_stat_vacuum_indexes WHERE relname = 'vacstat_t' ORDER BY indexrelname;
 
 -- index page-deletion path: deleting a contiguous key range empties whole
@@ -170,5 +197,8 @@ SELECT errors = 0 AS errors,
        blk_read_time >= 0 AS blk_read_time,
        blk_write_time >= 0 AS blk_write_time,
        delay_time >= 0 AS delay_time,
-       total_time > 0 AS total_time
+       total_time > 0 AS total_time,
+       wal_records > 0 AS wal_records,
+       wal_fpi >= 0 AS wal_fpi,
+       wal_bytes > 0 AS wal_bytes
   FROM pg_stat_vacuum_database WHERE dbname = current_database();
-- 
2.39.5 (Apple Git-154)



view thread (77+ messages)  latest in thread

reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: [email protected]
  Cc: [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected]
  Subject: Re: Vacuum statistics
  In-Reply-To: <[email protected]>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox