public inbox for [email protected]  
help / color / mirror / Atom feed
From: Alena Rybakina <[email protected]>
To: pgsql-hackers <[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, 28 Apr 2026 08:28:45 +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]>

On 28.04.2026 05:16, Alena Rybakina wrote:

> Hi, all!
>
> I have updated the core patch that implements the machinery for 
> collecting extended vacuum statistics (I didn't touch the first patch 
> that is ready for commit, only patches that are related to extension), 
> and rebased the ext_vacuum_statistics extension on top of it. The 
> split is intentional: the core only gathers metrics and hands them 
> out, while the actual storage and SQL-level access to the statistics 
> live entirely in the extension. If the extension is not loaded, the 
> overhead is essentially zero - we only fill a small struct on the 
> stack and do a NULL check on the hook.
>
> What was updated in the core
>
> The core gains the machinery and the hook through which the extension 
> receives metrics after each vacuum.
>
> The hook. A new hook has been added in pgstat - 
> set_report_vacuum_hook. It is fired once per vacuumed table and once 
> per vacuumed index, plus when forming the per-database aggregate. The 
> extension registers its handler in _PG_init and by default the hook is 
> NULL, so without an extension the core behaves exactly as before.
>
> The set of statistics is the same as before. Common to tables, indexes 
> and the database - hits and misses in shared buffers, number of 
> dirtied and written pages, WAL volume, buffer read and write times, 
> sleep time spent in delay points, total wall-clock vacuum time 
> (including I/O and lock waits), counter of emergency anti-wraparound 
> vacuums, number of interrupts and removed tuples. Tables additionally 
> report frozen tuples, pages marked all-frozen / all-visible in the 
> visibility map, number of scanned and removed pages, number of index 
> passes, etc. Indexes report freed pages.
>
> The least obvious part of the implementation is subtracting index 
> statistics from the table statistics. This is the bit worth 
> highlighting. The thing is that indexes are vacuumed before the heap, 
> and the buffer and WAL statistics that we capture at the heap level by 
> the end of the heap vacuum already include everything that was spent 
> on the indexes. If we simply expose the diff of 
> pgBufferUsage/pgWalUsage between start and end, the table ends up with 
> double-counted pages/WAL: once in its own report, and a second time 
> inside the reports of its indexes. This is especially noticeable with 
> parallel index vacuum: workers accumulate their usage in the leader 
> only after they finish, so without subtraction the heap report would 
> receive the combined cost of all workers as a "bonus".
>
> To handle this, as each index finishes vacuuming, its counters are 
> accumulated into the state of the current operation, and at the moment 
> the heap report is built these sums are subtracted out. As a result, 
> the extension receives clean numbers: "this is what was actually spent 
> on the table itself", and separately "this is what was actually spent 
> on each index". The behaviour is idempotent for both serial and 
> parallel vacuum.
>
> The ext_vacuum_statistics extension
>
> The extension registers the hook handler and stores the received data 
> through the pgstat custom statistics infrastructure. That is, vacuum 
> counters are kept not in the extension's own files, but together with 
> the regular cumulative statistics - they survive a restart and are 
> reset together with pg_stat_reset_*. Access is provided through three 
> views: one for tables, one for indexes, and one with the per-database 
> aggregate.
>
> Filtering
>
> This is where the main flexibility lives - the extension does not 
> force "collect everything", but lets you choose both what to track and 
> which metrics to keep.
>
> By object type. You can limit collection to databases only (without 
> per-table detail), to tables only, or collect both. Among tables, you 
> can additionally filter system / user / all.
>
> By an explicit list. An alternative to "by type" is a whitelist: you 
> turn the corresponding mode on, and the extension starts collecting 
> statistics only for the databases and tables that were explicitly 
> registered via add_track_database / add_track_relation (with matching 
> remove_* for removal). When the lists are off, the type filter is in 
> effect; when they are on, only the list applies. This is convenient 
> when you are interested in monitoring specific "hot" tables and do not 
> want to spend memory on statistics for everything else.
> This list is persisted to disk, and there is one more non-trivial part 
> here. List changes are concurrent - multiple sessions may call 
> add_track_* simultaneously, plus there is an object-access hook that 
> cleans the entry on DROP. To avoid ending up with a torn file, access 
> to the list is serialized via a dedicated LWLock tranche (requested 
> from a shmem_request_hook), and the file itself is written atomically: 
> first into a temporary file, then fflush + pg_fsync + durable_rename. 
> All I/O return codes are checked; on error the temporary file is 
> removed and the real one is left untouched; PG_TRY/PG_CATCH guarantees 
> cleanup on ereport(ERROR). Reading the list takes the same lock in 
> shared mode, so a concurrent write cannot tear the load.
>
> By metric category. There is also a GUC that takes a list and turns on 
> the categories of interest - buffers, WAL, general counters, timings 
> (or all). Unwanted categories are simply skipped on the hook handler 
> side and never make it into the pgstat entry, which reduces the 
> overhead of the handler itself. This is useful when, for example, only 
> timings are needed - in that case the extension does not waste time 
> copying the buffer and WAL fields.
>
> Privileges. The add_track_* / remove_track_* functions require 
> superuser or pg_read_all_stats. At the SQL level, EXECUTE is revoked 
> from PUBLIC and granted only to pg_read_all_stats, so a regular user 
> has no access to mutating the list. The views are unrestricted, like 
> regular statistics.
>
> What is in the patches
>
> 0002-Machinery-for-grabbing-extended-vacuum-statistics.patch - the 
> machinery in the core plus the hook.
> 0003-ext_vacuum_statistics-...patch - the extension itself, filtering, 
> views, tests.
>
I noticed CI's complaints during extension installation and fixed it.

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

From 19f5a39f7e97d3fc2d18415ba2c51ffcd3b32f49 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Mon, 30 Mar 2026 09:07:24 +0300
Subject: [PATCH 1/3] 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         |   1 +
 .../specs/vacuum-extending-freeze.spec        | 117 +++++++++++
 src/test/regress/expected/rules.out           |  12 +-
 11 files changed, 391 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 08d5b824552..3467abf6d8a 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 4fd470702aa..f055ec3819c 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 73a1c1c4670..71e993c8783 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 b2ca28f83ba..92e1f60a080 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 1408de387ea..b6f064338fe 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 fa9ae79082b..f8241268017 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12769,4 +12769,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 dfa2e837638..7db36cf8add 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 00000000000..994a8df56df
--- /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 1578ba191c8..91ffc57ebd4 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -126,3 +126,4 @@ test: serializable-parallel-3
 test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
+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 00000000000..17c204e2326
--- /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 a65a5bf0c4f..096e4f763f3 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 3a5e0bd82578d1fea63d6bda229dc4d0b224684e Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Mon, 2 Mar 2026 23:09:32 +0300
Subject: [PATCH 2/3] Machinery for grabbing extended vacuum statistics.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add infrastructure inside lazy vacuum to gather extended per-vacuum
metrics and expose them to extensions via a new hook. Core itself
does not persist these metrics — that is the job of an extension
(see ext_vacuum_statistics).

Statistics are gathered separately for tables and indexes according
to vacuum phases. The ExtVacReport union and type field distinguish
PGSTAT_EXTVAC_TABLE vs PGSTAT_EXTVAC_INDEX. Heap vacuum stats are
sent to the cumulative statistics system after vacuum has processed
the indexes. Database vacuum statistics aggregate per-table and
per-index statistics within the database.

Common for tables, indexes, and database: total_blks_hit, total_blks_read
and total_blks_dirtied are the number of hit, miss and dirtied pages
in shared buffers during a vacuum operation. total_blks_dirtied counts
only pages dirtied by this vacuum. blk_read_time and blk_write_time
track access and flush time for buffer pages; blk_write_time can stay
zero if no flushes occurred. total_time is wall-clock time from start
to finish, including idle time (I/O and lock waits). delay_time is
total vacuum sleep time in vacuum delay points.

Both table and index report tuples_deleted (tuples removed by the vacuum),
pages_removed (pages by which relation storage was reduced) and
pages_deleted (freed pages; file size may remain unchanged). These are
independent of WAL and buffer stats and are not summed at the database
level.

Table only: pages_frozen (pages marked all-frozen in the visibility map),
pages_all_visible (pages marked all-visible in the visibility map),
wraparound_failsafe_count (number of urgent anti-wraparound vacuums).

Table and database share wraparound_failsafe (count of urgent anti-wraparound
cleanups). Database only: errors (number of error-level errors caught
during vacuum).

set_report_vacuum_hook (set_report_vacuum_hook_type) -- called
once per vacuumed relation/index with a PgStat_VacuumRelationCounts
payload tagged by ExtVacReportType (PGSTAT_EXTVAC_TABLE / _INDEX /
_DB / _INVALID).

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]>
---
 src/backend/access/heap/vacuumlazy.c         | 234 ++++++++++++++++++-
 src/backend/commands/vacuum.c                |   4 +
 src/backend/commands/vacuumparallel.c        |  12 +
 src/backend/utils/activity/pgstat_relation.c |  24 ++
 src/include/commands/vacuum.h                |  29 +++
 src/include/pgstat.h                         |  69 ++++++
 6 files changed, 367 insertions(+), 5 deletions(-)

diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 39395aed0d5..e4d4c93d641 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -283,6 +283,8 @@ typedef struct LVRelState
 	/* Error reporting state */
 	char	   *dbname;
 	char	   *relnamespace;
+	Oid			reloid;
+	Oid			indoid;
 	char	   *relname;
 	char	   *indname;		/* Current index name */
 	BlockNumber blkno;			/* used only for heap operations */
@@ -410,6 +412,15 @@ typedef struct LVRelState
 	 * been permanently disabled.
 	 */
 	BlockNumber eager_scan_remaining_fails;
+
+	int32		wraparound_failsafe_count;	/* # of emergency vacuums for
+											 * anti-wraparound */
+
+	/*
+	 * We need to accumulate index statistics for later subtraction from heap
+	 * stats.
+	 */
+	PgStat_VacuumRelationCounts extVacReportIdx;
 } LVRelState;
 
 
@@ -485,6 +496,166 @@ static void restore_vacuum_error_info(LVRelState *vacrel,
 									  const LVSavedErrInfo *saved_vacrel);
 
 
+/* Extended vacuum statistics functions */
+
+/*
+ * extvac_stats_start - Save cut-off values before start of relation processing.
+ */
+static void
+extvac_stats_start(Relation rel, LVExtStatCounters * counters)
+{
+	memset(counters, 0, sizeof(LVExtStatCounters));
+	counters->starttime = GetCurrentTimestamp();
+	counters->walusage = pgWalUsage;
+	counters->bufusage = pgBufferUsage;
+	counters->VacuumDelayTime = VacuumDelayTime;
+	counters->blocks_fetched = 0;
+	counters->blocks_hit = 0;
+
+	if (rel->pgstat_info && pgstat_track_counts)
+	{
+		counters->blocks_fetched = rel->pgstat_info->counts.blocks_fetched;
+		counters->blocks_hit = rel->pgstat_info->counts.blocks_hit;
+	}
+}
+
+/*
+ * extvac_stats_end - Finish extended vacuum statistic gathering and form report.
+ */
+static void
+extvac_stats_end(Relation rel, LVExtStatCounters * counters,
+				 PgStat_CommonCounts * report)
+{
+	WalUsage	walusage;
+	BufferUsage bufusage;
+	TimestampTz endtime;
+	long		secs;
+	int			usecs;
+
+	memset(report, 0, sizeof(PgStat_CommonCounts));
+	memset(&walusage, 0, sizeof(WalUsage));
+	WalUsageAccumDiff(&walusage, &pgWalUsage, &counters->walusage);
+	memset(&bufusage, 0, sizeof(BufferUsage));
+	BufferUsageAccumDiff(&bufusage, &pgBufferUsage, &counters->bufusage);
+	endtime = GetCurrentTimestamp();
+	TimestampDifference(counters->starttime, endtime, &secs, &usecs);
+
+	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;
+	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) +
+		INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_read_time);
+	report->blk_write_time = INSTR_TIME_GET_MILLISEC(bufusage.local_blk_write_time) +
+		INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_write_time);
+	report->delay_time = VacuumDelayTime - counters->VacuumDelayTime;
+	report->total_time = secs * 1000.0 + usecs / 1000.0;
+
+	if (rel->pgstat_info && pgstat_track_counts)
+	{
+		report->blks_fetched = rel->pgstat_info->counts.blocks_fetched - counters->blocks_fetched;
+		report->blks_hit = rel->pgstat_info->counts.blocks_hit - counters->blocks_hit;
+	}
+}
+
+/*
+ * extvac_stats_start_idx - Start extended vacuum statistic gathering for index.
+ */
+void
+extvac_stats_start_idx(Relation rel, IndexBulkDeleteResult *stats,
+					   LVExtStatCountersIdx * counters)
+{
+	extvac_stats_start(rel, &counters->common);
+	counters->pages_deleted = 0;
+	counters->tuples_removed = 0;
+
+	if (stats != NULL)
+	{
+		counters->tuples_removed = stats->tuples_removed;
+		counters->pages_deleted = stats->pages_deleted;
+	}
+}
+
+
+/*
+ * extvac_stats_end_idx - Finish extended vacuum statistic gathering for index.
+ */
+void
+extvac_stats_end_idx(Relation rel, IndexBulkDeleteResult *stats,
+					 LVExtStatCountersIdx * counters, PgStat_VacuumRelationCounts * report)
+{
+	memset(report, 0, sizeof(PgStat_VacuumRelationCounts));
+	extvac_stats_end(rel, &counters->common, &report->common);
+	report->type = PGSTAT_EXTVAC_INDEX;
+
+	if (stats != NULL)
+	{
+		report->common.tuples_deleted = stats->tuples_removed - counters->tuples_removed;
+		report->index.pages_deleted = stats->pages_deleted - counters->pages_deleted;
+	}
+}
+
+/*
+ * Accumulate index stats into vacrel for later subtraction from heap stats.
+ * It needs to prevent double-counting of stats for heaps that
+ * include indexes because indexes are vacuumed before the heap.
+ * We need to be careful with buffer usage and wal usage during parallel vacuum
+ * because they are accumulated summarly for all indexes at once by leader after
+ * all workers have finished.
+ */
+static void
+accumulate_idxs_vacuum_statistics(LVRelState *vacrel,
+								  PgStat_VacuumRelationCounts * extVacIdxStats)
+{
+	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.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;
+}
+
+/* Build heap-specific extended stats */
+static void
+accumulate_heap_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCounts * extVacStats)
+{
+	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->table.index_vacuum_count = vacrel->num_index_scans;
+	extVacStats->common.wraparound_failsafe_count = vacrel->wraparound_failsafe_count;
+
+	/* Hook is invoked from pgstat_report_vacuum() when extstats is passed */
+
+	/* Subtract index stats from heap to avoid double-counting */
+	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.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;
+}
 
 /*
  * Helper to set up the eager scanning state for vacuuming a single relation.
@@ -643,7 +814,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;
 
+	memset(&extVacReport, 0, sizeof(extVacReport));
 	verbose = (params->options & VACOPT_VERBOSE) != 0;
 	instrument = (verbose || (AmAutoVacuumWorkerProcess() &&
 							  params->log_vacuum_min_duration >= 0));
@@ -660,6 +834,9 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	/* Used for instrumentation and stats report */
 	starttime = GetCurrentTimestamp();
 
+	if (set_report_vacuum_hook)
+		extvac_stats_start(rel, &extVacCounters);
+
 	pgstat_progress_start_command(PROGRESS_COMMAND_VACUUM,
 								  RelationGetRelid(rel));
 	if (AmAutoVacuumWorkerProcess())
@@ -687,7 +864,9 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel->dbname = get_database_name(MyDatabaseId);
 	vacrel->relnamespace = get_namespace_name(RelationGetNamespace(rel));
 	vacrel->relname = pstrdup(RelationGetRelationName(rel));
+	vacrel->reloid = RelationGetRelid(rel);
 	vacrel->indname = NULL;
+	memset(&vacrel->extVacReportIdx, 0, sizeof(vacrel->extVacReportIdx));
 	vacrel->phase = VACUUM_ERRCB_PHASE_UNKNOWN;
 	vacrel->verbose = verbose;
 	errcallback.callback = vacuum_error_callback;
@@ -803,6 +982,9 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel->rel_pages = orig_rel_pages = RelationGetNumberOfBlocks(rel);
 	vacrel->vistest = GlobalVisTestFor(rel);
 
+	/* Initialize wraparound failsafe count for extended vacuum stats */
+	vacrel->wraparound_failsafe_count = 0;
+
 	/* Initialize state used to track oldest extant XID/MXID */
 	vacrel->NewRelfrozenXid = vacrel->cutoffs.OldestXmin;
 	vacrel->NewRelminMxid = vacrel->cutoffs.OldestMxact;
@@ -985,11 +1167,26 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	 * soon in cases where the failsafe prevented significant amounts of heap
 	 * vacuuming.
 	 */
-	pgstat_report_vacuum(rel,
-						 Max(vacrel->new_live_tuples, 0),
-						 vacrel->recently_dead_tuples +
-						 vacrel->missed_dead_tuples,
-						 starttime);
+	if (set_report_vacuum_hook)
+	{
+		extvac_stats_end(rel, &extVacCounters, &extVacReport.common);
+		accumulate_heap_vacuum_statistics(vacrel, &extVacReport);
+
+		pgstat_report_vacuum_ext(rel,
+								 Max(vacrel->new_live_tuples, 0),
+								 vacrel->recently_dead_tuples +
+								 vacrel->missed_dead_tuples,
+								 starttime,
+								 &extVacReport);
+	}
+	else
+		pgstat_report_vacuum_ext(rel,
+								 Max(vacrel->new_live_tuples, 0),
+								 vacrel->recently_dead_tuples +
+								 vacrel->missed_dead_tuples,
+								 starttime,
+								 NULL);
+
 	pgstat_progress_end_command();
 
 	if (instrument)
@@ -2903,6 +3100,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
@@ -3015,7 +3213,11 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 {
 	IndexVacuumInfo ivinfo;
 	LVSavedErrInfo saved_err_info;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
 
+	if (set_report_vacuum_hook)
+		extvac_stats_start_idx(indrel, istat, &extVacCounters);
 	ivinfo.index = indrel;
 	ivinfo.heaprel = vacrel->rel;
 	ivinfo.analyze_only = false;
@@ -3033,6 +3235,7 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 	 */
 	Assert(vacrel->indname == NULL);
 	vacrel->indname = pstrdup(RelationGetRelationName(indrel));
+	vacrel->indoid = RelationGetRelid(indrel);
 	update_vacuum_error_info(vacrel, &saved_err_info,
 							 VACUUM_ERRCB_PHASE_VACUUM_INDEX,
 							 InvalidBlockNumber, InvalidOffsetNumber);
@@ -3041,6 +3244,14 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 	istat = vac_bulkdel_one_index(&ivinfo, istat, vacrel->dead_items,
 								  vacrel->dead_items_info);
 
+	if (set_report_vacuum_hook)
+	{
+		memset(&extVacReport, 0, sizeof(extVacReport));
+		extvac_stats_end_idx(indrel, istat, &extVacCounters, &extVacReport);
+		pgstat_report_vacuum_ext(indrel, -1, -1, 0, &extVacReport);
+		accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+	}
+
 	/* Revert to the previous phase information for error traceback */
 	restore_vacuum_error_info(vacrel, &saved_err_info);
 	pfree(vacrel->indname);
@@ -3065,7 +3276,11 @@ lazy_cleanup_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 {
 	IndexVacuumInfo ivinfo;
 	LVSavedErrInfo saved_err_info;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
 
+	if (set_report_vacuum_hook)
+		extvac_stats_start_idx(indrel, istat, &extVacCounters);
 	ivinfo.index = indrel;
 	ivinfo.heaprel = vacrel->rel;
 	ivinfo.analyze_only = false;
@@ -3084,12 +3299,21 @@ lazy_cleanup_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 	 */
 	Assert(vacrel->indname == NULL);
 	vacrel->indname = pstrdup(RelationGetRelationName(indrel));
+	vacrel->indoid = RelationGetRelid(indrel);
 	update_vacuum_error_info(vacrel, &saved_err_info,
 							 VACUUM_ERRCB_PHASE_INDEX_CLEANUP,
 							 InvalidBlockNumber, InvalidOffsetNumber);
 
 	istat = vac_cleanup_one_index(&ivinfo, istat);
 
+	if (set_report_vacuum_hook)
+	{
+		memset(&extVacReport, 0, sizeof(extVacReport));
+		extvac_stats_end_idx(indrel, istat, &extVacCounters, &extVacReport);
+		pgstat_report_vacuum_ext(indrel, -1, -1, 0, &extVacReport);
+		accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+	}
+
 	/* 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/commands/vacuum.c b/src/backend/commands/vacuum.c
index 99d0db82ed7..a7fb73173f5 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 (msec). */
+double		VacuumDelayTime = 0;
+
 /* non-export function prototypes */
 static List *expand_vacuum_rel(VacuumRelation *vrel,
 							   MemoryContext vac_context, int options);
@@ -2561,6 +2564,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 41cefcfde54..200f12a2d1b 100644
--- a/src/backend/commands/vacuumparallel.c
+++ b/src/backend/commands/vacuumparallel.c
@@ -1076,6 +1076,8 @@ parallel_vacuum_process_one_index(ParallelVacuumState *pvs, Relation indrel,
 	IndexBulkDeleteResult *istat = NULL;
 	IndexBulkDeleteResult *istat_res;
 	IndexVacuumInfo ivinfo;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
 
 	/*
 	 * Update the pointer to the corresponding bulk-deletion result if someone
@@ -1084,6 +1086,8 @@ parallel_vacuum_process_one_index(ParallelVacuumState *pvs, Relation indrel,
 	if (indstats->istat_updated)
 		istat = &(indstats->istat);
 
+	if (set_report_vacuum_hook)
+		extvac_stats_start_idx(indrel, istat, &extVacCounters);
 	ivinfo.index = indrel;
 	ivinfo.heaprel = pvs->heaprel;
 	ivinfo.analyze_only = false;
@@ -1112,6 +1116,13 @@ parallel_vacuum_process_one_index(ParallelVacuumState *pvs, Relation indrel,
 				 RelationGetRelationName(indrel));
 	}
 
+	if (set_report_vacuum_hook)
+	{
+		memset(&extVacReport, 0, sizeof(extVacReport));
+		extvac_stats_end_idx(indrel, istat_res, &extVacCounters, &extVacReport);
+		pgstat_report_vacuum_ext(indrel, -1, -1, 0, &extVacReport);
+	}
+
 	/*
 	 * Copy the index bulk-deletion result returned from ambulkdelete and
 	 * amvacuumcleanup to the DSM segment if it's the first cycle because they
@@ -1276,6 +1287,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_relation.c b/src/backend/utils/activity/pgstat_relation.c
index 92e1f60a080..226d7aa06d5 100644
--- a/src/backend/utils/activity/pgstat_relation.c
+++ b/src/backend/utils/activity/pgstat_relation.c
@@ -272,6 +272,30 @@ pgstat_report_vacuum(Relation rel, PgStat_Counter livetuples,
 	(void) pgstat_flush_backend(false, PGSTAT_BACKEND_FLUSH_IO);
 }
 
+/*
+ * Hook for extensions to receive extended vacuum statistics.
+ * NULL when no extension has registered.
+ */
+set_report_vacuum_hook_type set_report_vacuum_hook = NULL;
+
+/*
+ * Report extended vacuum statistics to extensions via set_report_vacuum_hook.
+ * When livetuples/deadtuples/starttime are provided (heap case), also calls
+ * pgstat_report_vacuum. For indexes, pass -1, -1, 0 to skip pgstat_report_vacuum.
+ */
+void
+pgstat_report_vacuum_ext(Relation rel, PgStat_Counter livetuples,
+						 PgStat_Counter deadtuples, TimestampTz starttime,
+						 PgStat_VacuumRelationCounts * extstats)
+{
+	pgstat_report_vacuum(rel, livetuples, deadtuples, starttime);
+
+	if (extstats != NULL && set_report_vacuum_hook)
+		(*set_report_vacuum_hook) (RelationGetRelid(rel),
+								   rel->rd_rel->relisshared,
+								   extstats);
+}
+
 /*
  * Report that the table was just analyzed and flush IO statistics.
  *
diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h
index 956d9cea36d..a925f7da992 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
@@ -354,6 +356,33 @@ extern PGDLLIMPORT pg_atomic_uint32 *VacuumSharedCostBalance;
 extern PGDLLIMPORT pg_atomic_uint32 *VacuumActiveNWorkers;
 extern PGDLLIMPORT int VacuumCostBalanceLocal;
 
+/* Cumulative storage to report total vacuum delay time (msec). */
+extern PGDLLIMPORT double VacuumDelayTime;
+
+/* Counters for extended vacuum statistics gathering */
+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;
+
+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 PGDLLIMPORT bool VacuumFailsafeActive;
 extern PGDLLIMPORT double vacuum_cost_delay;
 extern PGDLLIMPORT int vacuum_cost_limit;
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index 7db36cf8add..8d934973dc1 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -93,6 +93,64 @@ typedef struct PgStat_FunctionCounts
 /*
  * Working state needed to accumulate per-function-call timing statistics.
  */
+/*
+ * Extended vacuum statistics - passed to extensions via set_report_vacuum_hook.
+ * Type of entry: table (heap), index, or database aggregate.
+ */
+typedef enum ExtVacReportType
+{
+	PGSTAT_EXTVAC_INVALID = 0,
+	PGSTAT_EXTVAC_TABLE = 1,
+	PGSTAT_EXTVAC_INDEX = 2,
+	PGSTAT_EXTVAC_DB = 3,
+}			ExtVacReportType;
+
+typedef struct PgStat_CommonCounts
+{
+	int64		total_blks_read;
+	int64		total_blks_hit;
+	int64		total_blks_dirtied;
+	int64		total_blks_written;
+	int64		blks_fetched;
+	int64		blks_hit;
+	int64		wal_records;
+	int64		wal_fpi;
+	uint64		wal_bytes;
+	double		blk_read_time;
+	double		blk_write_time;
+	double		delay_time;
+	double		total_time;
+	int32		wraparound_failsafe_count;
+	int32		interrupts_count;
+	int64		tuples_deleted;
+}			PgStat_CommonCounts;
+
+typedef struct PgStat_VacuumRelationCounts
+{
+	PgStat_CommonCounts common;
+	ExtVacReportType type;
+	union
+	{
+		struct
+		{
+			int64		tuples_frozen;
+			int64		recently_dead_tuples;
+			int64		missed_dead_tuples;
+			int64		pages_scanned;
+			int64		pages_removed;
+			int64		vm_new_frozen_pages;
+			int64		vm_new_visible_pages;
+			int64		vm_new_visible_frozen_pages;
+			int64		missed_dead_pages;
+			int64		index_vacuum_count;
+		}			table;
+		struct
+		{
+			int64		pages_deleted;
+		}			index;
+	};
+}			PgStat_VacuumRelationCounts;
+
 typedef struct PgStat_FunctionCallUsage
 {
 	/* Link to function's hashtable entry (must still be there at exit!) */
@@ -703,6 +761,17 @@ extern void pgstat_unlink_relation(Relation rel);
 extern void pgstat_report_vacuum(Relation rel, PgStat_Counter livetuples,
 								 PgStat_Counter deadtuples,
 								 TimestampTz starttime);
+
+extern void pgstat_report_vacuum_ext(Relation rel,
+									 PgStat_Counter livetuples,
+									 PgStat_Counter deadtuples,
+									 TimestampTz starttime,
+									 PgStat_VacuumRelationCounts * extstats);
+
+/* Hook for extensions to receive extended vacuum statistics */
+typedef void (*set_report_vacuum_hook_type) (Oid tableoid, bool shared,
+											 PgStat_VacuumRelationCounts * params);
+extern PGDLLIMPORT set_report_vacuum_hook_type set_report_vacuum_hook;
 extern void pgstat_report_analyze(Relation rel,
 								  PgStat_Counter livetuples, PgStat_Counter deadtuples,
 								  bool resetcounter, TimestampTz starttime);
-- 
2.39.5 (Apple Git-154)


From 3011a3cfd9ee3d6e4d1c5a12e3d9984f6b6a194e Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 28 Apr 2026 03:43:29 +0300
Subject: [PATCH 3/3] ext_vacuum_statistics: extension for extended vacuum
 statistics

Introduce a new extension that collects extended per-vacuum
metrics via set_report_vacuum_hook and stores them through pgstat's
custom statistics infrastructure.

Tracking scope is controlled by GUCs:

  * vacuum_statistics.enabled       -- master switch
  * vacuum_statistics.object_types  -- databases / relations / all
  * vacuum_statistics.track_relations -- system / user / all
  * vacuum_statistics.track_{databases,relations}_from_list
          -- restrict tracking to objects registered via
             add_track_database() / add_track_relation();
             removal via remove_track_*() and OAT_DROP hook
  * vacuum_statistics.collect       -- buffers / wal /
            general / timing / all, consulted by ACCUM_IF() to skip
            unwanted categories at run time

 add_track_* / remove_track_* require superuser or pg_read_all_stats.
---
 contrib/Makefile                              |    1 +
 contrib/ext_vacuum_statistics/Makefile        |   24 +
 contrib/ext_vacuum_statistics/README.md       |  165 ++
 .../expected/ext_vacuum_statistics.out        |   52 +
 .../vacuum-extending-in-repetable-read.out    |   52 +
 .../ext_vacuum_statistics--1.0.sql            |  272 ++++
 .../ext_vacuum_statistics.conf                |    2 +
 .../ext_vacuum_statistics.control             |    5 +
 contrib/ext_vacuum_statistics/meson.build     |   41 +
 .../vacuum-extending-in-repetable-read.spec   |   59 +
 .../t/052_vacuum_extending_basic_test.pl      |  780 +++++++++
 .../t/053_vacuum_extending_freeze_test.pl     |  285 ++++
 .../t/054_vacuum_extending_gucs_test.pl       |  279 ++++
 .../ext_vacuum_statistics/vacuum_statistics.c | 1387 +++++++++++++++++
 contrib/meson.build                           |    1 +
 doc/src/sgml/contrib.sgml                     |    1 +
 doc/src/sgml/extvacuumstatistics.sgml         |  502 ++++++
 doc/src/sgml/filelist.sgml                    |    1 +
 18 files changed, 3909 insertions(+)
 create mode 100644 contrib/ext_vacuum_statistics/Makefile
 create mode 100644 contrib/ext_vacuum_statistics/README.md
 create mode 100644 contrib/ext_vacuum_statistics/expected/ext_vacuum_statistics.out
 create mode 100644 contrib/ext_vacuum_statistics/expected/vacuum-extending-in-repetable-read.out
 create mode 100644 contrib/ext_vacuum_statistics/ext_vacuum_statistics--1.0.sql
 create mode 100644 contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf
 create mode 100644 contrib/ext_vacuum_statistics/ext_vacuum_statistics.control
 create mode 100644 contrib/ext_vacuum_statistics/meson.build
 create mode 100644 contrib/ext_vacuum_statistics/specs/vacuum-extending-in-repetable-read.spec
 create mode 100644 contrib/ext_vacuum_statistics/t/052_vacuum_extending_basic_test.pl
 create mode 100644 contrib/ext_vacuum_statistics/t/053_vacuum_extending_freeze_test.pl
 create mode 100644 contrib/ext_vacuum_statistics/t/054_vacuum_extending_gucs_test.pl
 create mode 100644 contrib/ext_vacuum_statistics/vacuum_statistics.c
 create mode 100644 doc/src/sgml/extvacuumstatistics.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index 7d91fe77db3..3140f2bf844 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -19,6 +19,7 @@ SUBDIRS = \
 		dict_int	\
 		dict_xsyn	\
 		earthdistance	\
+		ext_vacuum_statistics \
 		file_fdw	\
 		fuzzystrmatch	\
 		hstore		\
diff --git a/contrib/ext_vacuum_statistics/Makefile b/contrib/ext_vacuum_statistics/Makefile
new file mode 100644
index 00000000000..ed80bdf28d0
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/Makefile
@@ -0,0 +1,24 @@
+# contrib/ext_vacuum_statistics/Makefile
+
+EXTENSION = ext_vacuum_statistics
+MODULE_big = ext_vacuum_statistics
+OBJS = vacuum_statistics.o
+DATA = ext_vacuum_statistics--1.0.sql
+PGFILEDESC = "ext_vacuum_statistics - convenience views for extended vacuum statistics"
+
+ISOLATION = vacuum-extending-in-repetable-read
+ISOLATION_OPTS = --temp-config=$(top_srcdir)/contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf
+TAP_TESTS = 1
+
+NO_INSTALLCHECK = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/ext_vacuum_statistics
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/ext_vacuum_statistics/README.md b/contrib/ext_vacuum_statistics/README.md
new file mode 100644
index 00000000000..51697eab023
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/README.md
@@ -0,0 +1,165 @@
+# ext_vacuum_statistics
+
+Extended vacuum statistics extension for PostgreSQL. It collects and exposes detailed per-table, per-index, and per-database vacuum statistics (buffer I/O, WAL, general, timing) via convenient views in the `ext_vacuum_statistics` schema.
+
+## Installation
+
+```
+./configure tmp_install="$(pwd)/my/inst"
+make clean && make && make install
+cd contrib/ext_vacuum_statistics
+make && make install
+```
+
+It is essential that the extension is listed in `shared_preload_libraries` because it registers a vacuum hook at server startup.
+
+In your `postgresql.conf`:
+
+```
+shared_preload_libraries = 'ext_vacuum_statistics'
+```
+
+Restart PostgreSQL.
+
+In your database:
+
+```sql
+CREATE EXTENSION ext_vacuum_statistics;
+```
+
+## Usage
+
+Query vacuum statistics via the provided views:
+
+```sql
+-- Per-table heap vacuum statistics
+SELECT * FROM ext_vacuum_statistics.pg_stats_vacuum_tables;
+
+-- Per-index vacuum statistics
+SELECT * FROM ext_vacuum_statistics.pg_stats_vacuum_indexes;
+
+-- Per-database aggregate vacuum statistics
+SELECT * FROM ext_vacuum_statistics.pg_stats_vacuum_database;
+```
+
+Example output:
+
+```
+ relname   | total_blks_read | total_blks_hit | wal_records | tuples_deleted | pages_removed
+-----------+-----------------+----------------+-------------+----------------+---------------
+ mytable   |             120 |            340 |          15 |            500 |            10
+```
+
+Reset statistics when needed:
+
+```sql
+SELECT ext_vacuum_statistics.vacuum_statistics_reset();
+```
+
+## Configuration (GUCs)
+
+| GUC | Default | Description |
+|-----|---------|-------------|
+| `vacuum_statistics.enabled` | on | Enable extended vacuum statistics collection |
+| `vacuum_statistics.object_types` | all | Object types for statistics: `all`, `databases`, `relations` |
+| `vacuum_statistics.track_relations` | all | When tracking relations: `all`, `system`, `user` |
+| `vacuum_statistics.track_databases_from_list` | off | If on, track only databases added via add_track_database |
+| `vacuum_statistics.track_relations_from_list` | off | If on, track only relations added via add_track_relation |
+
+## Memory usage
+
+Each tracked object (table, index, or database) uses approximately **232 bytes** of shared memory on Linux x86_64 (e.g. Ubuntu): common stats (buffers, WAL, timing) ~144 bytes; type + union ~88 bytes (union holds table-specific or index-specific fields, allocated size is the same for both).
+
+The exact size depends on the platform. Call `ext_vacuum_statistics.shared_memory_size()` to get the total shared memory used by the extension. The GUCs provided by the extension allow controlling the amount of memory used: `vacuum_statistics.object_types` to track only databases or relations, `vacuum_statistics.track_relations` to restrict to user or system tables/indexes, and `track_*_from_list` to track only selected databases and relations.
+
+Example: a database with 1000 tables and 2000 indexes, all tracked, uses about **700 KB** on Ubuntu (3001 entries × 232 bytes). Per-database entries add one entry per tracked database.
+
+## Advanced tuning
+
+### Track only database-level stats
+
+```sql
+SET vacuum_statistics.object_types = 'databases';
+```
+
+Statistics are accumulated per database; per-relation views remain empty.
+
+### Track only user or system tables
+
+```sql
+SET vacuum_statistics.object_types = 'relations';
+SET vacuum_statistics.track_relations = 'user';   -- skip system catalogs
+-- or
+SET vacuum_statistics.track_relations = 'system'; -- only system catalogs
+```
+
+### Filter by database or relation OIDs
+
+Add OIDs via functions (persisted to `pg_stat/ext_vacuum_statistics_track.oid`) and enable filtering:
+
+```sql
+-- Add databases and relations to track
+SELECT ext_vacuum_statistics.add_track_database(16384);
+SELECT ext_vacuum_statistics.add_track_relation(16384, 16385);  -- dboid, reloid
+SELECT ext_vacuum_statistics.add_track_relation(0, 16386);      -- rel 16386 in any db
+
+-- Enable list-based filtering (off = track all)
+SET vacuum_statistics.track_databases_from_list = on;
+SET vacuum_statistics.track_relations_from_list = on;
+```
+
+Remove OIDs when no longer needed:
+
+```sql
+SELECT ext_vacuum_statistics.remove_track_database(16384);
+SELECT ext_vacuum_statistics.remove_track_relation(16384, 16385);
+```
+
+Inspect the current tracking configuration:
+
+```sql
+SELECT * FROM ext_vacuum_statistics.track_list();
+```
+
+Returns `track_kind`, `dboid`, `reloid`. When `dboid` or `reloid` is NULL, statistics are collected for all.
+
+## Recipes
+
+**Reduce overhead by tracking only databases:**
+
+```sql
+SET vacuum_statistics.object_types = 'databases';
+```
+
+**Track only a specific table in a specific database:**
+
+```sql
+SELECT ext_vacuum_statistics.add_track_database(
+    (SELECT oid FROM pg_database WHERE datname = current_database())
+);
+SELECT ext_vacuum_statistics.add_track_relation(
+    (SELECT oid FROM pg_database WHERE datname = current_database()),
+    'mytable'::regclass
+);
+SET vacuum_statistics.track_databases_from_list = on;
+SET vacuum_statistics.track_relations_from_list = on;
+```
+
+**Disable statistics collection temporarily:**
+
+```sql
+SET vacuum_statistics.enabled = off;
+```
+
+## Views
+
+| View | Description |
+|------|-------------|
+| `ext_vacuum_statistics.pg_stats_vacuum_tables` | Per-table heap vacuum stats (pages scanned, tuples deleted, WAL, timing, etc.) |
+| `ext_vacuum_statistics.pg_stats_vacuum_indexes` | Per-index vacuum stats |
+| `ext_vacuum_statistics.pg_stats_vacuum_database` | Per-database aggregate vacuum stats |
+
+## Limitations
+
+- Must be loaded via `shared_preload_libraries`; it cannot be loaded on demand.
+- Tracking configuration (`add_track_*`, `remove_track_*`) is stored in a file and shared across all databases in the cluster.
diff --git a/contrib/ext_vacuum_statistics/expected/ext_vacuum_statistics.out b/contrib/ext_vacuum_statistics/expected/ext_vacuum_statistics.out
new file mode 100644
index 00000000000..89c9594dea8
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/expected/ext_vacuum_statistics.out
@@ -0,0 +1,52 @@
+-- ext_vacuum_statistics regression test
+
+-- Create extension
+CREATE EXTENSION ext_vacuum_statistics;
+
+-- Verify schema and views exist
+SELECT nspname FROM pg_namespace WHERE nspname = 'ext_vacuum_statistics';
+     nspname      
+------------------
+ ext_vacuum_statistics
+(1 row)
+
+-- Views should be queryable (may return empty if no vacuum has run)
+SELECT COUNT(*) >= 0 FROM ext_vacuum_statistics.pg_stats_vacuum_tables;
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT COUNT(*) >= 0 FROM ext_vacuum_statistics.pg_stats_vacuum_indexes;
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT COUNT(*) >= 0 FROM ext_vacuum_statistics.pg_stats_vacuum_database;
+ ?column? 
+----------
+ t
+(1 row)
+
+-- Verify views have expected columns
+SELECT COUNT(*) AS tables_cols FROM information_schema.columns
+WHERE table_schema = 'ext_vacuum_statistics' AND table_name = 'tables';
+ tables_cols 
+-------------
+          28
+(1 row)
+
+SELECT COUNT(*) AS indexes_cols FROM information_schema.columns
+WHERE table_schema = 'ext_vacuum_statistics' AND table_name = 'indexes';
+ indexes_cols 
+--------------
+            20
+(1 row)
+
+SELECT COUNT(*) AS database_cols FROM information_schema.columns
+WHERE table_schema = 'ext_vacuum_statistics' AND table_name = 'database';
+ database_cols 
+---------------
+             15
+(1 row)
diff --git a/contrib/ext_vacuum_statistics/expected/vacuum-extending-in-repetable-read.out b/contrib/ext_vacuum_statistics/expected/vacuum-extending-in-repetable-read.out
new file mode 100644
index 00000000000..6b381f9d232
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/expected/vacuum-extending-in-repetable-read.out
@@ -0,0 +1,52 @@
+unused step name: s2_delete
+Parsed test spec with 2 sessions
+
+starting permutation: s2_insert s2_print_vacuum_stats_table s1_begin_repeatable_read s2_update s2_insert_interrupt s2_vacuum s2_print_vacuum_stats_table s1_commit s2_checkpoint s2_vacuum s2_print_vacuum_stats_table
+step s2_insert: INSERT INTO test_vacuum_stat_isolation(id, ival) SELECT ival, ival%10 FROM generate_series(1,1000) As ival;
+step s2_print_vacuum_stats_table: 
+    SELECT
+        vt.relname, vt.tuples_deleted, vt.recently_dead_tuples, vt.missed_dead_tuples, vt.missed_dead_pages, vt.tuples_frozen
+    FROM ext_vacuum_statistics.pg_stats_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|tuples_frozen
+-------+--------------+--------------------+------------------+-----------------+-------------
+(0 rows)
+
+step s1_begin_repeatable_read: 
+    BEGIN transaction ISOLATION LEVEL REPEATABLE READ;
+    select count(ival) from test_vacuum_stat_isolation where id>900;
+
+count
+-----
+  100
+(1 row)
+
+step s2_update: UPDATE test_vacuum_stat_isolation SET ival = ival + 2 where id > 900;
+step s2_insert_interrupt: INSERT INTO test_vacuum_stat_isolation values (1,1);
+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, vt.tuples_frozen
+    FROM ext_vacuum_statistics.pg_stats_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|tuples_frozen
+--------------------------+--------------+--------------------+------------------+-----------------+-------------
+test_vacuum_stat_isolation|             0|                 100|                 0|                0|            0
+(1 row)
+
+step s1_commit: COMMIT;
+step s2_checkpoint: CHECKPOINT;
+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, vt.tuples_frozen
+    FROM ext_vacuum_statistics.pg_stats_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|tuples_frozen
+--------------------------+--------------+--------------------+------------------+-----------------+-------------
+test_vacuum_stat_isolation|           100|                 100|                 0|                0|          101
+(1 row)
+
diff --git a/contrib/ext_vacuum_statistics/ext_vacuum_statistics--1.0.sql b/contrib/ext_vacuum_statistics/ext_vacuum_statistics--1.0.sql
new file mode 100644
index 00000000000..aa3a9ec9699
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/ext_vacuum_statistics--1.0.sql
@@ -0,0 +1,272 @@
+/*-------------------------------------------------------------------------
+ *
+ * ext_vacuum_statistics--1.0.sql
+ *    Extended vacuum statistics via hook and custom storage
+ *
+ * This extension collects extended vacuum statistics via set_report_vacuum_hook
+ * and stores them in shared memory.
+ *
+ *-------------------------------------------------------------------------
+ */
+
+\echo Use "CREATE EXTENSION ext_vacuum_statistics" to load this file. \quit
+
+CREATE SCHEMA IF NOT EXISTS ext_vacuum_statistics;
+
+COMMENT ON SCHEMA ext_vacuum_statistics IS
+  'Extended vacuum statistics (heap, index, database)';
+
+-- Reset functions
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.extvac_reset_entry(
+    dboid oid,
+    relid oid,
+    type int4
+)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'extvac_reset_entry'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.extvac_reset_db_entry(dboid oid)
+RETURNS bigint
+AS 'MODULE_PATHNAME', 'extvac_reset_db_entry'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.vacuum_statistics_reset()
+RETURNS bigint
+AS 'MODULE_PATHNAME', 'vacuum_statistics_reset'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.shared_memory_size()
+RETURNS bigint
+AS 'MODULE_PATHNAME', 'extvac_shared_memory_size'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+COMMENT ON FUNCTION ext_vacuum_statistics.shared_memory_size() IS
+  'Total shared memory in bytes used by the extension for vacuum statistics.';
+
+-- Add/remove OIDs for tracking
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.add_track_database(dboid oid)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'evs_add_track_database'
+LANGUAGE C STRICT;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.remove_track_database(dboid oid)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'evs_remove_track_database'
+LANGUAGE C STRICT;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.add_track_relation(dboid oid, reloid oid)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'evs_add_track_relation'
+LANGUAGE C STRICT;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.remove_track_relation(dboid oid, reloid oid)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'evs_remove_track_relation'
+LANGUAGE C STRICT;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.track_list()
+RETURNS TABLE(track_kind text, dboid oid, reloid oid)
+AS 'MODULE_PATHNAME', 'evs_track_list'
+LANGUAGE C STRICT;
+
+COMMENT ON FUNCTION ext_vacuum_statistics.track_list() IS
+  'List of database and relation OIDs for which vacuum statistics are collected.';
+
+-- Track-list mutation requires superuser or pg_read_all_stats; hide the
+-- functions from PUBLIC so the error is also produced for ordinary users
+-- before the C-level privilege check runs.
+REVOKE ALL ON FUNCTION ext_vacuum_statistics.add_track_database(oid) FROM PUBLIC;
+REVOKE ALL ON FUNCTION ext_vacuum_statistics.remove_track_database(oid) FROM PUBLIC;
+REVOKE ALL ON FUNCTION ext_vacuum_statistics.add_track_relation(oid, oid) FROM PUBLIC;
+REVOKE ALL ON FUNCTION ext_vacuum_statistics.remove_track_relation(oid, oid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION ext_vacuum_statistics.add_track_database(oid) TO pg_read_all_stats;
+GRANT EXECUTE ON FUNCTION ext_vacuum_statistics.remove_track_database(oid) TO pg_read_all_stats;
+GRANT EXECUTE ON FUNCTION ext_vacuum_statistics.add_track_relation(oid, oid) TO pg_read_all_stats;
+GRANT EXECUTE ON FUNCTION ext_vacuum_statistics.remove_track_relation(oid, oid) TO pg_read_all_stats;
+
+-- Internal C function to fetch table vacuum stats
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.pg_stats_get_vacuum_tables(
+    IN  dboid oid,
+    IN  reloid oid,
+    OUT relid oid,
+    OUT total_blks_read bigint,
+    OUT total_blks_hit bigint,
+    OUT total_blks_dirtied bigint,
+    OUT total_blks_written bigint,
+    OUT wal_records bigint,
+    OUT wal_fpi bigint,
+    OUT wal_bytes numeric,
+    OUT blk_read_time double precision,
+    OUT blk_write_time double precision,
+    OUT delay_time double precision,
+    OUT total_time double precision,
+    OUT wraparound_failsafe_count integer,
+    OUT rel_blks_read bigint,
+    OUT rel_blks_hit bigint,
+    OUT tuples_deleted bigint,
+    OUT pages_scanned bigint,
+    OUT pages_removed bigint,
+    OUT vm_new_frozen_pages bigint,
+    OUT vm_new_visible_pages bigint,
+    OUT vm_new_visible_frozen_pages bigint,
+    OUT tuples_frozen bigint,
+    OUT recently_dead_tuples bigint,
+    OUT index_vacuum_count bigint,
+    OUT missed_dead_pages bigint,
+    OUT missed_dead_tuples bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_stats_get_vacuum_tables'
+LANGUAGE C STRICT STABLE;
+
+-- Internal C function to fetch index vacuum stats
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.pg_stats_get_vacuum_indexes(
+    IN  dboid oid,
+    IN  reloid oid,
+    OUT relid oid,
+    OUT total_blks_read bigint,
+    OUT total_blks_hit bigint,
+    OUT total_blks_dirtied bigint,
+    OUT total_blks_written bigint,
+    OUT wal_records bigint,
+    OUT wal_fpi bigint,
+    OUT wal_bytes numeric,
+    OUT blk_read_time double precision,
+    OUT blk_write_time double precision,
+    OUT delay_time double precision,
+    OUT total_time double precision,
+    OUT wraparound_failsafe_count integer,
+    OUT rel_blks_read bigint,
+    OUT rel_blks_hit bigint,
+    OUT tuples_deleted bigint,
+    OUT pages_deleted bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_stats_get_vacuum_indexes'
+LANGUAGE C STRICT STABLE;
+
+-- Internal C function to fetch database vacuum stats
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.pg_stats_get_vacuum_database(
+    IN  dboid oid,
+    OUT dbid oid,
+    OUT total_blks_read bigint,
+    OUT total_blks_hit bigint,
+    OUT total_blks_dirtied bigint,
+    OUT total_blks_written bigint,
+    OUT wal_records bigint,
+    OUT wal_fpi bigint,
+    OUT wal_bytes numeric,
+    OUT blk_read_time double precision,
+    OUT blk_write_time double precision,
+    OUT delay_time double precision,
+    OUT total_time double precision,
+    OUT wraparound_failsafe_count integer,
+    OUT interrupts_count integer
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_stats_get_vacuum_database'
+LANGUAGE C STRICT STABLE;
+
+-- View: vacuum statistics per table (heap)
+CREATE VIEW ext_vacuum_statistics.pg_stats_vacuum_tables AS
+SELECT
+  rel.oid AS relid,
+  ns.nspname AS schema,
+  rel.relname AS relname,
+  db.datname AS dbname,
+  stats.total_blks_read,
+  stats.total_blks_hit,
+  stats.total_blks_dirtied,
+  stats.total_blks_written,
+  stats.wal_records,
+  stats.wal_fpi,
+  stats.wal_bytes,
+  stats.blk_read_time,
+  stats.blk_write_time,
+  stats.delay_time,
+  stats.total_time,
+  stats.wraparound_failsafe_count,
+  stats.rel_blks_read,
+  stats.rel_blks_hit,
+  stats.tuples_deleted,
+  stats.pages_scanned,
+  stats.pages_removed,
+  stats.vm_new_frozen_pages,
+  stats.vm_new_visible_pages,
+  stats.vm_new_visible_frozen_pages,
+  stats.tuples_frozen,
+  stats.recently_dead_tuples,
+  stats.index_vacuum_count,
+  stats.missed_dead_pages,
+  stats.missed_dead_tuples
+FROM pg_database db,
+     pg_class rel,
+     pg_namespace ns,
+     LATERAL ext_vacuum_statistics.pg_stats_get_vacuum_tables(db.oid, rel.oid) stats
+WHERE db.datname = current_database()
+  AND rel.relkind = 'r'
+  AND rel.relnamespace = ns.oid
+  AND rel.oid = stats.relid;
+
+COMMENT ON VIEW ext_vacuum_statistics.pg_stats_vacuum_tables IS
+  'Extended vacuum statistics per table (heap)';
+
+-- View: vacuum statistics per index
+CREATE VIEW ext_vacuum_statistics.pg_stats_vacuum_indexes AS
+SELECT
+  rel.oid AS indexrelid,
+  ns.nspname AS schema,
+  rel.relname AS indexrelname,
+  db.datname AS dbname,
+  stats.total_blks_read,
+  stats.total_blks_hit,
+  stats.total_blks_dirtied,
+  stats.total_blks_written,
+  stats.wal_records,
+  stats.wal_fpi,
+  stats.wal_bytes,
+  stats.blk_read_time,
+  stats.blk_write_time,
+  stats.delay_time,
+  stats.total_time,
+  stats.wraparound_failsafe_count,
+  stats.rel_blks_read,
+  stats.rel_blks_hit,
+  stats.tuples_deleted,
+  stats.pages_deleted
+FROM pg_database db,
+     pg_class rel,
+     pg_namespace ns,
+     LATERAL ext_vacuum_statistics.pg_stats_get_vacuum_indexes(db.oid, rel.oid) stats
+WHERE db.datname = current_database()
+  AND rel.relkind = 'i'
+  AND rel.relnamespace = ns.oid
+  AND rel.oid = stats.relid;
+
+COMMENT ON VIEW ext_vacuum_statistics.pg_stats_vacuum_indexes IS
+  'Extended vacuum statistics per index';
+
+-- View: vacuum statistics per database (aggregate)
+CREATE VIEW ext_vacuum_statistics.pg_stats_vacuum_database AS
+SELECT
+  db.oid AS dboid,
+  db.datname AS dbname,
+  stats.total_blks_read AS db_blks_read,
+  stats.total_blks_hit AS db_blks_hit,
+  stats.total_blks_dirtied AS db_blks_dirtied,
+  stats.total_blks_written AS db_blks_written,
+  stats.wal_records AS db_wal_records,
+  stats.wal_fpi AS db_wal_fpi,
+  stats.wal_bytes AS db_wal_bytes,
+  stats.blk_read_time AS db_blk_read_time,
+  stats.blk_write_time AS db_blk_write_time,
+  stats.delay_time AS db_delay_time,
+  stats.total_time AS db_total_time,
+  stats.wraparound_failsafe_count AS db_wraparound_failsafe_count,
+  stats.interrupts_count
+FROM pg_database db
+LEFT JOIN LATERAL ext_vacuum_statistics.pg_stats_get_vacuum_database(db.oid) stats ON db.oid = stats.dbid;
+
+COMMENT ON VIEW ext_vacuum_statistics.pg_stats_vacuum_database IS
+  'Extended vacuum statistics per database (aggregate)';
diff --git a/contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf b/contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf
new file mode 100644
index 00000000000..9b711487623
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf
@@ -0,0 +1,2 @@
+# Config for ext_vacuum_statistics regression tests
+shared_preload_libraries = 'ext_vacuum_statistics'
diff --git a/contrib/ext_vacuum_statistics/ext_vacuum_statistics.control b/contrib/ext_vacuum_statistics/ext_vacuum_statistics.control
new file mode 100644
index 00000000000..518350a64b7
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/ext_vacuum_statistics.control
@@ -0,0 +1,5 @@
+# ext_vacuum_statistics extension
+comment = 'Extended vacuum statistics via hook (requires shared_preload_libraries)'
+default_version = '1.0'
+relocatable = true
+module_pathname = '$libdir/ext_vacuum_statistics'
diff --git a/contrib/ext_vacuum_statistics/meson.build b/contrib/ext_vacuum_statistics/meson.build
new file mode 100644
index 00000000000..72338baa500
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/meson.build
@@ -0,0 +1,41 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+#
+# ext_vacuum_statistics - extended vacuum statistics via hook
+# Requires shared_preload_libraries = 'ext_vacuum_statistics'
+
+ext_vacuum_statistics_sources = files(
+  'vacuum_statistics.c',
+)
+
+ext_vacuum_statistics = shared_module('ext_vacuum_statistics',
+  ext_vacuum_statistics_sources,
+  kwargs: contrib_mod_args + {
+    'dependencies': contrib_mod_args['dependencies'],
+  },
+)
+contrib_targets += ext_vacuum_statistics
+
+install_data(
+  'ext_vacuum_statistics.control',
+  'ext_vacuum_statistics--1.0.sql',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'ext_vacuum_statistics',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'isolation': {
+    'specs': [
+      'vacuum-extending-in-repetable-read',
+    ],
+    'regress_args': ['--temp-config', files('ext_vacuum_statistics.conf')],
+    'runningcheck': false,
+  },
+  'tap': {
+    'tests': [
+      't/052_vacuum_extending_basic_test.pl',
+      't/053_vacuum_extending_freeze_test.pl',
+    ],
+  },
+}
diff --git a/contrib/ext_vacuum_statistics/specs/vacuum-extending-in-repetable-read.spec b/contrib/ext_vacuum_statistics/specs/vacuum-extending-in-repetable-read.spec
new file mode 100644
index 00000000000..4891e248cca
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/specs/vacuum-extending-in-repetable-read.spec
@@ -0,0 +1,59 @@
+# Test for checking recently_dead_tuples, tuples_deleted and frozen tuples in ext_vacuum_statistics.pg_stats_vacuum_tables.
+# recently_dead_tuples values are counted when vacuum hasn't cleared tuples because they were deleted recently.
+# recently_dead_tuples aren't increased after releasing lock compared with tuples_deleted, which increased
+# by the value of the cleared tuples that the vacuum managed to clear.
+
+setup
+{
+    CREATE TABLE test_vacuum_stat_isolation(id int, ival int) WITH (autovacuum_enabled = off);
+    CREATE EXTENSION ext_vacuum_statistics;
+    SET track_io_timing = on;
+}
+
+teardown
+{
+    DROP EXTENSION ext_vacuum_statistics CASCADE;
+    DROP TABLE test_vacuum_stat_isolation CASCADE;
+    RESET track_io_timing;
+}
+
+session s1
+setup {
+    SET track_io_timing = on;
+}
+step s1_begin_repeatable_read {
+    BEGIN transaction ISOLATION LEVEL REPEATABLE READ;
+    select count(ival) from test_vacuum_stat_isolation where id>900;
+}
+step s1_commit { COMMIT; }
+
+session s2
+setup {
+    SET track_io_timing = on;
+}
+step s2_insert                  { INSERT INTO test_vacuum_stat_isolation(id, ival) SELECT ival, ival%10 FROM generate_series(1,1000) As ival; }
+step s2_update                  { UPDATE test_vacuum_stat_isolation SET ival = ival + 2 where id > 900; }
+step s2_delete                  { DELETE FROM test_vacuum_stat_isolation where id > 900; }
+step s2_insert_interrupt        { INSERT INTO test_vacuum_stat_isolation values (1,1); }
+step s2_vacuum                  { VACUUM test_vacuum_stat_isolation; }
+step s2_checkpoint              { CHECKPOINT; }
+step s2_print_vacuum_stats_table
+{
+    SELECT
+        vt.relname, vt.tuples_deleted, vt.recently_dead_tuples, vt.missed_dead_tuples, vt.missed_dead_pages, vt.tuples_frozen
+    FROM ext_vacuum_statistics.pg_stats_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+}
+
+permutation
+    s2_insert
+    s2_print_vacuum_stats_table
+    s1_begin_repeatable_read
+    s2_update
+    s2_insert_interrupt
+    s2_vacuum
+    s2_print_vacuum_stats_table
+    s1_commit
+    s2_checkpoint
+    s2_vacuum
+    s2_print_vacuum_stats_table
diff --git a/contrib/ext_vacuum_statistics/t/052_vacuum_extending_basic_test.pl b/contrib/ext_vacuum_statistics/t/052_vacuum_extending_basic_test.pl
new file mode 100644
index 00000000000..9463d5145f4
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/t/052_vacuum_extending_basic_test.pl
@@ -0,0 +1,780 @@
+# Copyright (c) 2025 PostgreSQL Global Development Group
+# Test cumulative vacuum stats system using TAP
+#
+# This test validates the accuracy and behavior of cumulative vacuum statistics
+# across heap tables, indexes, and databases using:
+#
+#   • ext_vacuum_statistics.pg_stats_vacuum_tables
+#   • ext_vacuum_statistics.pg_stats_vacuum_indexes
+#   • ext_vacuum_statistics.pg_stats_vacuum_database
+#
+# A polling helper function repeatedly checks the stats views until expected
+# deltas appear or a configurable timeout expires. This guarantees that
+# stats-collector propagation delays do not lead to flaky test behavior.
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+#------------------------------------------------------------------------------
+# Test harness setup
+#------------------------------------------------------------------------------
+
+my $node = PostgreSQL::Test::Cluster->new('stat_vacuum');
+$node->init;
+
+# Configure the server: preload extension and logging level
+$node->append_conf('postgresql.conf', q{
+    shared_preload_libraries = 'ext_vacuum_statistics'
+    log_min_messages = notice
+});
+
+my $stderr;
+my $base_stats;
+my $wals;
+my $ibase_stats;
+my $iwals;
+
+$node->start(
+    '>' => \$base_stats,
+	'2>' => \$stderr
+);
+
+#------------------------------------------------------------------------------
+# Database creation and initialization
+#------------------------------------------------------------------------------
+
+$node->safe_psql('postgres', q{
+    CREATE DATABASE statistic_vacuum_database_regression;
+    CREATE EXTENSION ext_vacuum_statistics;
+});
+# Main test database name and number of rows to insert
+my $dbname   = 'statistic_vacuum_database_regression';
+my $size_tab = 1000;
+
+# Enable required session settings and force the stats collector to flush next
+$node->safe_psql($dbname, q{
+    SET track_functions = 'all';
+    SELECT pg_stat_force_next_flush();
+});
+
+#------------------------------------------------------------------------------
+# Create test table and populate it
+#------------------------------------------------------------------------------
+
+$node->safe_psql(
+    $dbname,
+    "CREATE EXTENSION ext_vacuum_statistics;
+     CREATE TABLE vestat (x int PRIMARY KEY)
+         WITH (autovacuum_enabled = off, fillfactor = 10);
+     INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     ANALYZE vestat;"
+);
+
+#------------------------------------------------------------------------------
+# Timing parameters for polling loops
+#------------------------------------------------------------------------------
+
+my $timeout    = 30;     # overall wait timeout in seconds
+my $interval   = 0.015;  # poll interval in seconds (15 ms)
+my $start_time = time();
+my $updated    = 0;
+
+#------------------------------------------------------------------------------
+# wait_for_vacuum_stats
+#
+# Polls ext_vacuum_statistics.pg_stats_vacuum_tables and ext_vacuum_statistics.pg_stats_vacuum_indexes until both the
+# table-level and index-level counters exceed the provided baselines, or until
+# the configured timeout elapses.
+#
+# Expected named args (baseline values):
+#   tab_tuples_deleted
+#   tab_wal_records
+#   idx_tuples_deleted
+#   idx_wal_records
+#
+# Returns: 1 if the condition is met before timeout, 0 otherwise.
+#------------------------------------------------------------------------------
+
+sub wait_for_vacuum_stats {
+    my (%args) = @_;
+    my $tab_tuples_deleted = ($args{tab_tuples_deleted} or 0);
+    my $tab_wal_records    = ($args{tab_wal_records} or 0);
+    my $idx_tuples_deleted = ($args{idx_tuples_deleted} or 0);
+    my $idx_wal_records    = ($args{idx_wal_records} or 0);
+
+    my $start = time();
+    while ((time() - $start) < $timeout) {
+
+        my $result_query = $node->safe_psql(
+            $dbname,
+            "VACUUM vestat;
+             SELECT
+                (SELECT (tuples_deleted > $tab_tuples_deleted AND wal_records > $tab_wal_records)
+                  FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+                  WHERE relname = 'vestat')
+                AND
+                (SELECT (tuples_deleted > $idx_tuples_deleted AND wal_records > $idx_wal_records)
+                  FROM ext_vacuum_statistics.pg_stats_vacuum_indexes
+                  WHERE indexrelname = 'vestat_pkey');"
+        );
+
+        return 1 if ($result_query eq 't');
+
+        sleep($interval);
+    }
+
+    return 0;
+}
+
+#------------------------------------------------------------------------------
+# Variables to hold vacuum-stat snapshots for later comparisons
+#------------------------------------------------------------------------------
+
+my $vm_new_visible_frozen_pages = 0;
+my $tuples_deleted = 0;
+my $pages_scanned = 0;
+my $pages_removed = 0;
+my $wal_records = 0;
+my $wal_bytes = 0;
+my $wal_fpi = 0;
+
+my $index_tuples_deleted = 0;
+my $index_pages_deleted = 0;
+my $index_wal_records = 0;
+my $index_wal_bytes = 0;
+my $index_wal_fpi = 0;
+
+my $vm_new_visible_frozen_pages_prev = 0;
+my $tuples_deleted_prev = 0;
+my $pages_scanned_prev = 0;
+my $pages_removed_prev = 0;
+my $wal_records_prev = 0;
+my $wal_bytes_prev = 0;
+my $wal_fpi_prev = 0;
+
+my $index_tuples_deleted_prev = 0;
+my $index_pages_deleted_prev = 0;
+my $index_wal_records_prev = 0;
+my $index_wal_bytes_prev = 0;
+my $index_wal_fpi_prev = 0;
+
+#------------------------------------------------------------------------------
+# fetch_vacuum_stats
+#
+# Reads current values of relevant vacuum counters for the test table and its
+# primary index, storing them in package variables for subsequent comparisons.
+#------------------------------------------------------------------------------
+
+sub fetch_vacuum_stats {
+    # fetch actual base vacuum statistics
+    my $base_statistics = $node->safe_psql(
+        $dbname,
+        "SELECT vm_new_visible_frozen_pages, tuples_deleted, pages_scanned, pages_removed, wal_records, wal_bytes, wal_fpi
+           FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+          WHERE relname = 'vestat';"
+    );
+
+    $base_statistics =~ s/\s*\|\s*/ /g;   # transform " | " into space
+    ($vm_new_visible_frozen_pages, $tuples_deleted, $pages_scanned, $pages_removed, $wal_records, $wal_bytes, $wal_fpi)
+        = split /\s+/, $base_statistics;
+
+    # --- index stats ---
+    my $index_base_statistics = $node->safe_psql(
+        $dbname,
+        "SELECT tuples_deleted, pages_deleted, wal_records, wal_bytes, wal_fpi
+           FROM ext_vacuum_statistics.pg_stats_vacuum_indexes
+          WHERE indexrelname = 'vestat_pkey';"
+    );
+
+    $index_base_statistics =~ s/\s*\|\s*/ /g;   # transform " | " into space
+    ($index_tuples_deleted, $index_pages_deleted, $index_wal_records, $index_wal_bytes, $index_wal_fpi)
+        = split /\s+/, $index_base_statistics;
+}
+
+#------------------------------------------------------------------------------
+# save_vacuum_stats
+#
+# Save current values (previously fetched by fetch_vacuum_stats) so that we
+# later fetch new values and compare them.
+#------------------------------------------------------------------------------
+sub save_vacuum_stats {
+    $vm_new_visible_frozen_pages_prev = $vm_new_visible_frozen_pages;
+    $tuples_deleted_prev = $tuples_deleted;
+    $pages_scanned_prev = $pages_scanned;
+    $pages_removed_prev = $pages_removed;
+    $wal_records_prev = $wal_records;
+    $wal_bytes_prev = $wal_bytes;
+    $wal_fpi_prev = $wal_fpi;
+
+    $index_tuples_deleted_prev = $index_tuples_deleted;
+    $index_pages_deleted_prev = $index_pages_deleted;
+    $index_wal_records_prev = $index_wal_records;
+    $index_wal_bytes_prev = $index_wal_bytes;
+    $index_wal_fpi_prev = $index_wal_fpi;
+}
+
+#------------------------------------------------------------------------------
+# print_vacuum_stats_on_error
+#
+# Print values in case of an error
+#------------------------------------------------------------------------------
+sub print_vacuum_stats_on_error {
+    diag(
+            "Statistics in the failed test\n" .
+            "Table statistics:\n" .
+            "  Before test:\n" .
+            "    vm_new_visible_frozen_pages = $vm_new_visible_frozen_pages_prev\n" .
+            "    tuples_deleted    = $tuples_deleted_prev\n" .
+            "    pages_scanned     = $pages_scanned_prev\n" .
+            "    pages_removed     = $pages_removed_prev\n" .
+            "    wal_records       = $wal_records_prev\n" .
+            "    wal_bytes         = $wal_bytes_prev\n" .
+            "    wal_fpi           = $wal_fpi_prev\n" .
+            "  After test:\n" .
+            "    vm_new_visible_frozen_pages = $vm_new_visible_frozen_pages\n" .
+            "    tuples_deleted    = $tuples_deleted\n" .
+            "    pages_scanned     = $pages_scanned\n" .
+            "    pages_removed     = $pages_removed\n" .
+            "    wal_records       = $wal_records\n" .
+            "    wal_bytes         = $wal_bytes\n" .
+            "    wal_fpi           = $wal_fpi\n" .
+            "Index statistics:\n" .
+            "   Before test:\n" .
+            "    tuples_deleted    = $index_tuples_deleted_prev\n" .
+            "    pages_deleted     = $index_pages_deleted_prev\n" .
+            "    wal_records       = $index_wal_records_prev\n" .
+            "    wal_bytes         = $index_wal_bytes_prev\n" .
+            "    wal_fpi           = $index_wal_fpi_prev\n" .
+            "  After test:\n" .
+            "    tuples_deleted    = $index_tuples_deleted\n" .
+            "    pages_deleted     = $index_pages_deleted\n" .
+            "    wal_records       = $index_wal_records\n" .
+            "    wal_bytes         = $index_wal_bytes\n" .
+            "    wal_fpi           = $index_wal_fpi\n"
+    );
+};
+
+sub fetch_error_base_db_vacuum_statistics {
+    my (%args) = @_;
+
+    # Validate presence of required args (allow 0 as valid numeric baseline)
+    die "database name required"
+      unless exists $args{database_name} && defined $args{database_name};
+    my $database_name       = $args{database_name};
+
+    # fetch actual base database vacuum statistics
+    my $base_statistics = $node->safe_psql(
+    $database_name,
+    "SELECT db_blks_hit, db_blks_dirtied,
+            db_blks_written, db_wal_records,
+            db_wal_fpi, db_wal_bytes
+       FROM ext_vacuum_statistics.pg_stats_vacuum_database, pg_database
+      WHERE pg_database.datname = '$dbname'
+            AND pg_database.oid = ext_vacuum_statistics.pg_stats_vacuum_database.dboid;"
+    );
+    $base_statistics =~ s/\s*\|\s*/ /g;   # transform " | " in space
+    my ($db_blks_hit, $total_blks_dirtied, $total_blks_written,
+        $wal_records, $wal_fpi, $wal_bytes) = split /\s+/, $base_statistics;
+
+    diag(
+            "BASE STATS MISMATCH FOR DATABASE $dbname:\n" .
+            "    db_blks_hit        = $db_blks_hit\n" .
+            "    total_blks_dirtied = $total_blks_dirtied\n" .
+            "    total_blks_written = $total_blks_written\n" .
+            "    wal_records        = $wal_records\n" .
+            "    wal_fpi            = $wal_fpi\n" .
+            "    wal_bytes          = $wal_bytes\n"
+    );
+}
+
+
+#------------------------------------------------------------------------------
+# Test 1: Delete half the rows, run VACUUM, and wait for stats to advance
+#------------------------------------------------------------------------------
+subtest 'Test 1: Delete half the rows, run VACUUM' => sub
+{
+
+$node->safe_psql($dbname, "DELETE FROM vestat WHERE x % 2 = 0;");
+$node->safe_psql($dbname, "VACUUM vestat;");
+
+# Poll the stats view until expected deltas appear or timeout
+$updated = wait_for_vacuum_stats(
+    tab_tuples_deleted => 0,
+    tab_wal_records => 0,
+    idx_tuples_deleted => 0,
+    idx_wal_records => 0,
+);
+ok($updated, 'vacuum stats updated after vacuuming half-deleted table (tuples_deleted and wal_fpi advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds after vacuuming half-deleted table";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted > $tuples_deleted_prev, 'table tuples_deleted has increased');
+ok($pages_scanned > $pages_scanned_prev, 'table pages_scanned has increased');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi > $wal_fpi_prev, 'table wal_fpi has increased');
+
+ok($index_pages_deleted == $index_pages_deleted_prev, 'index pages_deleted stay the same');
+ok($index_tuples_deleted > $index_tuples_deleted_prev, 'index tuples_deleted has increased');
+ok($index_wal_records > $index_wal_records_prev, 'index wal_records has increased');
+ok($index_wal_bytes > $index_wal_bytes_prev, 'index wal_bytes has increased');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 2: Delete all rows, run VACUUM, and wait for stats to advance
+#------------------------------------------------------------------------------
+subtest 'Test 2: Delete all rows, run VACUUM' => sub
+{
+save_vacuum_stats();
+
+$node->safe_psql($dbname, "DELETE FROM vestat;");
+$node->safe_psql($dbname, "VACUUM vestat;");
+
+$updated = wait_for_vacuum_stats(
+    tab_tuples_deleted => $tuples_deleted_prev,
+    tab_wal_records => $wal_records_prev,
+    idx_tuples_deleted => $index_tuples_deleted_prev,
+    idx_wal_records => $index_wal_records_prev,
+);
+
+ok($updated, 'vacuum stats updated after vacuuming all-deleted table (tuples_deleted and wal_records advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds after vacuuming all-deleted table";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages > $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages has increased');
+ok($tuples_deleted > $tuples_deleted_prev, 'table tuples_deleted has increased');
+ok($pages_scanned > $pages_scanned_prev, 'table pages_scanned has increased');
+ok($pages_removed > $pages_removed_prev, 'table pages_removed has increased');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi > 0, 'table wal_fpi has increased');
+
+ok($index_pages_deleted > $index_pages_deleted_prev, 'index pages_deleted has increased');
+ok($index_tuples_deleted > $index_tuples_deleted_prev, 'index tuples_deleted has increased');
+ok($index_wal_records > $index_wal_records_prev, 'index wal_records has increased');
+ok($index_wal_bytes > $index_wal_bytes_prev, 'index wal_bytes has increased');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 3: Test VACUUM FULL — it should not report to the stats collector
+#------------------------------------------------------------------------------
+subtest 'Test 3: Test VACUUM FULL — it should not report to the stats collector' => sub
+{
+save_vacuum_stats();
+
+$node->safe_psql(
+    $dbname,
+    "INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     CHECKPOINT;
+     DELETE FROM vestat;
+     VACUUM FULL vestat;"
+);
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted == $tuples_deleted_prev, 'table tuples_deleted stay the same');
+ok($pages_scanned == $pages_scanned_prev, 'table pages_scanned stay the same');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records == $wal_records_prev, 'table wal_records stay the same');
+ok($wal_bytes == $wal_bytes_prev, 'table wal_bytes stay the same');
+ok($wal_fpi == $wal_fpi_prev, 'table wal_fpi stay the same');
+
+ok($index_pages_deleted == $index_pages_deleted_prev, 'index pages_deleted stay the same');
+ok($index_tuples_deleted == $index_tuples_deleted_prev, 'index tuples_deleted stay the same');
+ok($index_wal_records == $index_wal_records_prev, 'index wal_records stay the same');
+ok($index_wal_bytes == $index_wal_bytes_prev, 'index wal_bytes stay the same');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 4: Update table, checkpoint, and VACUUM to provoke WAL/FPI accounting
+#------------------------------------------------------------------------------
+subtest 'Test 4: Update table, checkpoint, and VACUUM to provoke WAL/FPI accounting' => sub
+{
+
+save_vacuum_stats();
+
+$node->safe_psql(
+    $dbname,
+    "INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     CHECKPOINT;
+     UPDATE vestat SET x = x + 1000;
+     VACUUM vestat;"
+);
+
+$updated = wait_for_vacuum_stats(
+    tab_tuples_deleted => $tuples_deleted_prev,
+    tab_wal_records => $wal_records_prev,
+    idx_tuples_deleted => $index_tuples_deleted_prev,
+    idx_wal_records => $index_wal_records_prev,
+);
+
+ok($updated, 'vacuum stats updated after updating tuples in the table (tuples_deleted and wal_records advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted > $tuples_deleted_prev, 'table tuples_deleted has increased');
+ok($pages_scanned > $pages_scanned_prev, 'table pages_scanned has increased');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi > $wal_fpi_prev, 'table wal_fpi has increased');
+
+ok($index_pages_deleted > $index_pages_deleted_prev, 'index pages_deleted has increased');
+ok($index_tuples_deleted > $index_tuples_deleted_prev, 'index tuples_deleted has increased');
+ok($index_wal_records > $index_wal_records_prev, 'index wal_records has increased');
+ok($index_wal_bytes > $index_wal_bytes_prev, 'index wal_bytes has increased');
+ok($index_wal_fpi > $index_wal_fpi_prev, 'index wal_fpi has increased');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 5: Update table, trancate and vacuuming
+#------------------------------------------------------------------------------
+subtest 'Test 5: Update table, trancate and vacuuming' => sub
+{
+
+save_vacuum_stats();
+
+$node->safe_psql(
+    $dbname,
+    "INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     UPDATE vestat SET x = x + 1000;"
+);
+$node->safe_psql($dbname, "TRUNCATE vestat;");
+$node->safe_psql($dbname, "CHECKPOINT;");
+$node->safe_psql($dbname, "VACUUM vestat;");
+
+$updated = wait_for_vacuum_stats(
+    tab_wal_records => $wal_records_prev,
+);
+
+ok($updated, 'vacuum stats updated after updating tuples and trancation in the table (wal_records advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted == $tuples_deleted_prev, 'table tuples_deleted stay the same');
+ok($pages_scanned == $pages_scanned_prev, 'table pages_scanned stay the same');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi == $wal_fpi_prev, 'table wal_fpi stay the same');
+
+ok($index_pages_deleted == $index_pages_deleted_prev, 'index pages_deleted stay the same');
+ok($index_tuples_deleted == $index_tuples_deleted_prev, 'index tuples_deleted stay the same');
+ok($index_wal_records == $index_wal_records_prev, 'index wal_records stay the same');
+ok($index_wal_bytes == $index_wal_bytes_prev, 'index wal_bytes stay the same');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 6: Delete all tuples from table, trancate, and vacuuming
+#------------------------------------------------------------------------------
+subtest 'Test 6: Delete all tuples from table, trancate, and vacuuming' => sub
+{
+
+save_vacuum_stats();
+
+$node->safe_psql(
+    $dbname,
+    "INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     DELETE FROM vestat;
+     TRUNCATE vestat;
+     CHECKPOINT;
+     VACUUM vestat;"
+);
+
+$updated = wait_for_vacuum_stats(
+    tab_wal_records => $wal_records,
+);
+
+ok($updated, 'vacuum stats updated after deleting all tuples and trancation in the table (wal_records advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted == $tuples_deleted_prev, 'table tuples_deleted stay the same');
+ok($pages_scanned == $pages_scanned_prev, 'table pages_scanned stay the same');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi == $wal_fpi_prev, 'table wal_fpi stay the same');
+
+ok($index_pages_deleted == $index_pages_deleted_prev, 'index pages_deleted stay the same');
+ok($index_tuples_deleted == $index_tuples_deleted_prev, 'index tuples_deleted stay the same');
+ok($index_wal_records == $index_wal_records_prev, 'index wal_records stay the same');
+ok($index_wal_bytes == $index_wal_bytes_prev, 'index wal_bytes stay the same');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+my $dboid = $node->safe_psql(
+    $dbname,
+    "SELECT oid FROM pg_database WHERE datname = current_database();"
+);
+
+#-------------------------------------------------------------------------------------------------------
+# Test 7: Check if we return single vacuum statistics for particular relation from the current database
+#-------------------------------------------------------------------------------------------------------
+subtest 'Test 7: Check if we return vacuum statistics from the current database' => sub
+{
+save_vacuum_stats();
+
+my $reloid = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT oid FROM pg_class WHERE relname = 'vestat';
+    }
+);
+
+# Check if we can get vacuum statistics of particular heap relation in the current database
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_get_vacuum_tables((SELECT oid FROM pg_database WHERE datname = current_database()), $reloid);"
+);
+is($base_stats, 1, 'heap vacuum stats return from the current relation and database as expected');
+
+$reloid = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT oid FROM pg_class WHERE relname = 'vestat_pkey';
+    }
+);
+
+# Check if we can get vacuum statistics of particular index relation in the current database
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_get_vacuum_indexes((SELECT oid FROM pg_database WHERE datname = current_database()), $reloid);"
+);
+is($base_stats, 1, 'index vacuum stats return from the current relation and database as expected');
+
+# Check if we return empty results if vacuum statistics with particular oid doesn't exist
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_get_vacuum_tables((SELECT oid FROM pg_database WHERE datname = current_database()), 1);"
+);
+is($base_stats, 0, 'table vacuum stats return no rows, as expected');
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_get_vacuum_indexes((SELECT oid FROM pg_database WHERE datname = current_database()), 1);"
+);
+is($base_stats, 0, 'index vacuum stats return no rows, as expected');
+
+# Check if we can get vacuum statistics of all relations in the current database
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) > 0 FROM ext_vacuum_statistics.pg_stats_vacuum_tables;"
+);
+ok($base_stats eq 't', 'vacuum stats per all heap objects available');
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) > 0 FROM ext_vacuum_statistics.pg_stats_vacuum_indexes;"
+);
+ok($base_stats eq 't', 'vacuum stats per all index objects available');
+};
+
+#------------------------------------------------------------------------------
+# Test 8: Check relation-level vacuum statistics from another database
+#------------------------------------------------------------------------------
+subtest 'Test 8: Check relation-level vacuum statistics from another database' => sub
+{
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*)
+    FROM ext_vacuum_statistics.pg_stats_vacuum_indexes
+    WHERE indexrelname = 'vestat_pkey';"
+);
+is($base_stats, 0, 'check the printing index vacuum extended statistics from another database are not available');
+
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*)
+    FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+    WHERE relname = 'vestat';"
+);
+is($base_stats, 0, 'check the printing heap vacuum extended statistics from another database are not available');
+
+# Check that relations from another database are not visible in the view when querying from postgres
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'vestat';"
+);
+is($base_stats, 0, 'vacuum stats per all tables objects from another database are not available as expected');
+
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_vacuum_indexes WHERE indexrelname = 'vestat_pkey';"
+);
+is($base_stats, 0, 'vacuum stats per all index objects from another database are not available as expected');
+};
+
+#--------------------------------------------------------------------------------------
+# Test 9: Check database-level vacuum statistics from the current and another database
+#--------------------------------------------------------------------------------------
+subtest 'Test 9: Check database-level vacuum statistics from the current and another database' => sub
+{
+my $db_blk_hit = 0;
+my $total_blks_dirtied = 0;
+my $total_blks_written = 0;
+my $wal_records = 0;
+my $wal_fpi = 0;
+my $wal_bytes = 0;
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT db_blks_hit, db_blks_dirtied,
+            db_blks_written, db_wal_records,
+            db_wal_fpi, db_wal_bytes
+     FROM ext_vacuum_statistics.pg_stats_vacuum_database, pg_database
+     WHERE pg_database.datname = '$dbname'
+            AND pg_database.oid = ext_vacuum_statistics.pg_stats_vacuum_database.dboid;"
+);
+$base_stats =~ s/\s*\|\s*/ /g;   # transform " | " into space
+    ($db_blk_hit, $total_blks_dirtied, $total_blks_written, $wal_records, $wal_fpi, $wal_bytes)
+        = split /\s+/, $base_stats;
+
+ok($db_blk_hit > 0, 'db_blks_hit is more than 0');
+ok($total_blks_dirtied > 0, 'total_blks_dirtied is more than 0');
+ok($total_blks_written > 0, 'total_blks_written is more than 0');
+ok($wal_records > 0, 'wal_records is more than 0');
+ok($wal_fpi > 0, 'wal_fpi is more than 0');
+ok($wal_bytes > 0, 'wal_bytes is more than 0');
+
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*) = 1
+     FROM ext_vacuum_statistics.pg_stats_vacuum_database, pg_database
+     WHERE pg_database.datname = '$dbname'
+            AND pg_database.oid = ext_vacuum_statistics.pg_stats_vacuum_database.dboid;"
+);
+ok($base_stats eq 't', 'check database-level vacuum stats from another database are available');
+};
+
+#------------------------------------------------------------------------------
+# Test 10: Cleanup checks: ensure functions return empty sets for OID = 0
+#------------------------------------------------------------------------------
+subtest 'Test 10: Cleanup checks: ensure functions return empty sets for OID = 0' => sub
+{
+my $dboid = $node->safe_psql(
+    $dbname,
+    "SELECT oid FROM pg_database WHERE datname = current_database();"
+);
+
+# Vacuum statistics for invalid relation OID return empty
+$base_stats = $node->safe_psql(
+    $dbname,
+    q{
+       SELECT COUNT(*)
+         FROM ext_vacuum_statistics.pg_stats_get_vacuum_tables((SELECT oid FROM pg_database WHERE datname = current_database()), 0);
+    }
+);
+is($base_stats, 0, 'vacuum stats per heap from invalid relation OID return empty as expected');
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    q{
+       SELECT COUNT(*)
+         FROM ext_vacuum_statistics.pg_stats_get_vacuum_indexes((SELECT oid FROM pg_database WHERE datname = current_database()), 0);
+    }
+);
+is($base_stats, 0, 'vacuum stats per index from invalid relation OID return empty as expected');
+
+$node->safe_psql($dbname, q{
+    DROP TABLE vestat CASCADE;
+    VACUUM;
+});
+
+# Check that we don't print vacuum statistics for deleted objects
+$base_stats = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT COUNT(*)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relid = 0;
+    }
+);
+is($base_stats, 0, 'ext_vacuum_statistics.pg_stats_vacuum_tables correctly returns no rows for OID = 0');
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT COUNT(*)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_indexes WHERE indexrelid = 0;
+    }
+);
+is($base_stats, 0, 'ext_vacuum_statistics.pg_stats_vacuum_indexes correctly returns no rows for OID = 0');
+
+my $reloid = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT oid FROM pg_class WHERE relname = 'pg_shdepend';
+    }
+);
+
+$node->safe_psql($dbname, "VACUUM pg_shdepend;");
+
+# Check if we can get vacuum statistics for cluster relations (shared catalogs)
+$base_stats = $node->safe_psql(
+    $dbname,
+    qq{
+        SELECT count(*) > 0
+        FROM ext_vacuum_statistics.pg_stats_get_vacuum_tables((SELECT oid FROM pg_database WHERE datname = current_database()), $reloid);
+    }
+);
+
+is($base_stats, 't', 'vacuum stats for common heap objects available');
+
+my $indoid = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT oid FROM pg_class WHERE relname = 'pg_shdepend_reference_index';
+    }
+);
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    qq{
+        SELECT count(*) > 0
+        FROM ext_vacuum_statistics.pg_stats_get_vacuum_indexes((SELECT oid FROM pg_database WHERE datname = current_database()), $indoid);
+    }
+);
+
+is($base_stats, 't', 'vacuum stats for common index objects available');
+
+$node->safe_psql('postgres',
+    "DROP DATABASE $dbname;
+     VACUUM;"
+);
+
+$base_stats = $node->safe_psql(
+    'postgres',
+    q{
+       SELECT count(*) = 0
+        FROM ext_vacuum_statistics.pg_stats_get_vacuum_database(0);
+    }
+);
+is($base_stats, 't', 'vacuum stats from database with invalid database OID return empty, as expected');
+};
+
+$node->stop;
+
+done_testing();
diff --git a/contrib/ext_vacuum_statistics/t/053_vacuum_extending_freeze_test.pl b/contrib/ext_vacuum_statistics/t/053_vacuum_extending_freeze_test.pl
new file mode 100644
index 00000000000..4f8f025c63e
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/t/053_vacuum_extending_freeze_test.pl
@@ -0,0 +1,285 @@
+# Copyright (c) 2025 PostgreSQL Global Development Group
+#
+# Test cumulative vacuum stats using ext_vacuum_statistics extension (TAP)
+#
+# In short, this test validates the correctness and stability of cumulative
+# vacuum statistics accounting around freezing, visibility, and revision
+# tracking across multiple VACUUMs and backend operations.
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+#------------------------------------------------------------------------------
+# Test cluster setup
+#------------------------------------------------------------------------------
+
+my $node = PostgreSQL::Test::Cluster->new('ext_stat_vacuum');
+$node->init;
+
+# Configure the server: preload extension and aggressive freezing behavior
+$node->append_conf('postgresql.conf', q{
+    shared_preload_libraries = 'ext_vacuum_statistics'
+    log_min_messages = notice
+    vacuum_freeze_min_age = 0
+    vacuum_freeze_table_age = 0
+    vacuum_multixact_freeze_min_age = 0
+    vacuum_multixact_freeze_table_age = 0
+    vacuum_max_eager_freeze_failure_rate = 1.0
+    vacuum_failsafe_age = 0
+    vacuum_multixact_failsafe_age = 0
+    track_functions = 'all'
+});
+
+$node->start();
+
+#------------------------------------------------------------------------------
+# Database creation and initialization
+#------------------------------------------------------------------------------
+
+$node->safe_psql('postgres', q{
+    CREATE DATABASE statistic_vacuum_database_regression;
+});
+
+# Main test database name
+my $dbname = 'statistic_vacuum_database_regression';
+
+# Create extension
+$node->safe_psql($dbname, q{
+    CREATE EXTENSION ext_vacuum_statistics;
+});
+
+#------------------------------------------------------------------------------
+# Timing parameters for polling loops
+#------------------------------------------------------------------------------
+
+my $timeout    = 30;     # overall wait timeout in seconds
+my $interval   = 0.015;  # poll interval in seconds (15 ms)
+my $start_time = time();
+my $updated    = 0;
+
+#------------------------------------------------------------------------------
+# wait_for_vacuum_stats
+#
+# Polls ext_vacuum_statistics.pg_stats_vacuum_tables until the named columns exceed the
+# provided baseline values or until timeout.
+#
+#   tab_all_frozen_pages_count  => 0   # baseline numeric
+#   tab_all_visible_pages_count => 0   # baseline numeric
+#   run_vacuum                  => 0   # if true, run vacuum before polling
+#
+# Returns: 1 if the condition is met before timeout, 0 otherwise.
+#------------------------------------------------------------------------------
+sub wait_for_vacuum_stats {
+    my (%args) = @_;
+
+    my $tab_all_frozen_pages_count  = $args{tab_all_frozen_pages_count} || 0;
+    my $tab_all_visible_pages_count = $args{tab_all_visible_pages_count} || 0;
+    my $run_vacuum                  = $args{run_vacuum} ? 1 : 0;
+    my $result_query;
+
+    my $start = time();
+    my $sql;
+
+    # Run VACUUM once if requested, before polling
+    if ($run_vacuum) {
+        $node->safe_psql($dbname, 'VACUUM (FREEZE, VERBOSE) vestat');
+    }
+
+    while ((time() - $start) < $timeout) {
+
+        if ($run_vacuum) {
+            $sql = "
+            SELECT (vm_new_visible_frozen_pages > $tab_all_frozen_pages_count)
+               FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+              WHERE relname = 'vestat'";
+        }
+        else {
+            $sql = "
+            SELECT (pg_stat_get_frozen_page_marks_cleared(c.oid) > $tab_all_frozen_pages_count AND
+                     pg_stat_get_visible_page_marks_cleared(c.oid) > $tab_all_visible_pages_count)
+               FROM pg_class c
+              WHERE relname = 'vestat'";
+        }
+
+        $result_query = $node->safe_psql($dbname, $sql);
+
+        return 1 if (defined $result_query && $result_query eq 't');
+
+        sleep($interval);
+    }
+
+    return 0;
+}
+
+#------------------------------------------------------------------------------
+# Variables to hold vacuum statistics snapshots for comparisons
+#------------------------------------------------------------------------------
+
+my $vm_new_visible_frozen_pages = 0;
+
+my $rev_all_frozen_pages = 0;
+my $rev_all_visible_pages = 0;
+
+my $vm_new_visible_frozen_pages_prev = 0;
+
+my $rev_all_frozen_pages_prev = 0;
+my $rev_all_visible_pages_prev = 0;
+
+my $res;
+
+#------------------------------------------------------------------------------
+# fetch_vacuum_stats
+#
+# Loads current values of the relevant vacuum counters for the test table
+# into the package-level variables above so tests can compare later.
+#------------------------------------------------------------------------------
+
+sub fetch_vacuum_stats {
+    $vm_new_visible_frozen_pages = $node->safe_psql(
+        $dbname,
+        "SELECT vt.vm_new_visible_frozen_pages
+           FROM ext_vacuum_statistics.pg_stats_vacuum_tables vt
+          WHERE vt.relname = 'vestat';"
+    );
+
+    $rev_all_frozen_pages = $node->safe_psql(
+        $dbname,
+        "SELECT pg_stat_get_frozen_page_marks_cleared(c.oid)
+           FROM pg_class c
+          WHERE c.relname = 'vestat';"
+    );
+
+    $rev_all_visible_pages = $node->safe_psql(
+        $dbname,
+        "SELECT pg_stat_get_visible_page_marks_cleared(c.oid)
+           FROM pg_class c
+          WHERE c.relname = 'vestat';"
+    );
+}
+
+#------------------------------------------------------------------------------
+# save_vacuum_stats
+#------------------------------------------------------------------------------
+sub save_vacuum_stats {
+    $vm_new_visible_frozen_pages_prev = $vm_new_visible_frozen_pages;
+    $rev_all_frozen_pages_prev = $rev_all_frozen_pages;
+    $rev_all_visible_pages_prev = $rev_all_visible_pages;
+}
+
+#------------------------------------------------------------------------------
+# print_vacuum_stats_on_error
+#------------------------------------------------------------------------------
+sub print_vacuum_stats_on_error {
+    diag(
+            "Statistics in the failed test\n" .
+            "Table statistics:\n" .
+            "  Before test:\n" .
+            "    vm_new_visible_frozen_pages = $vm_new_visible_frozen_pages_prev\n" .
+            "    rev_all_frozen_pages = $rev_all_frozen_pages_prev\n" .
+            "    rev_all_visible_pages = $rev_all_visible_pages_prev\n" .
+            "  After test:\n" .
+            "    vm_new_visible_frozen_pages = $vm_new_visible_frozen_pages\n" .
+            "    rev_all_frozen_pages = $rev_all_frozen_pages\n" .
+            "    rev_all_visible_pages = $rev_all_visible_pages\n"
+    );
+};
+
+#------------------------------------------------------------------------------
+# Test 1: Create test table, populate it and run an initial vacuum to force freezing
+#------------------------------------------------------------------------------
+
+subtest 'Test 1: Create test table, populate it and run an initial vacuum to force freezing' => sub
+{
+$node->safe_psql($dbname, q{
+    CREATE TABLE vestat (x int)
+        WITH (autovacuum_enabled = off, fillfactor = 10);
+    INSERT INTO vestat SELECT x FROM generate_series(1, 1000) AS g(x);
+    ANALYZE vestat;
+    VACUUM (FREEZE, VERBOSE) vestat;
+});
+
+$updated = wait_for_vacuum_stats(
+    tab_all_frozen_pages_count  => 0,
+    tab_all_visible_pages_count => 0,
+    run_vacuum                  => 1,
+);
+
+ok($updated,
+   'vacuum stats updated after vacuuming the table (vm_new_visible_frozen_pages advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics to update after $timeout seconds during vacuum";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages > $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages has increased');
+ok($rev_all_frozen_pages == $rev_all_frozen_pages_prev, 'table rev_all_frozen_pages stay the same');
+ok($rev_all_visible_pages == $rev_all_visible_pages_prev, 'table rev_all_visible_pages stay the same');
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 2: Trigger backend updates
+# Backend activity should reset per-page visibility/freeze marks and increment revision counters
+#------------------------------------------------------------------------------
+subtest 'Test 2: Trigger backend updates' => sub
+{
+save_vacuum_stats();
+
+$node->safe_psql($dbname, q{
+    UPDATE vestat SET x = x + 1001;
+});
+
+$updated = wait_for_vacuum_stats(
+    tab_all_frozen_pages_count  => 0,
+    tab_all_visible_pages_count => 0,
+    run_vacuum                  => 0,
+);
+
+ok($updated,
+   'vacuum stats updated after backend tuple updates (rev_all_frozen_pages and rev_all_visible_pages advanced)')
+  or diag "Timeout waiting for vacuum stats update after $timeout seconds";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($rev_all_frozen_pages > $rev_all_frozen_pages_prev, 'table rev_all_frozen_pages has increased');
+ok($rev_all_visible_pages > $rev_all_visible_pages_prev, 'table rev_all_visible_pages has increased');
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 3: Force another vacuum after backend modifications - vacuum should restore freeze/visibility
+#------------------------------------------------------------------------------
+subtest 'Test 3: Force another vacuum after backend modifications - vacuum should restore freeze/visibility' => sub
+{
+save_vacuum_stats();
+
+$node->safe_psql($dbname, q{ VACUUM vestat; });
+
+$updated = wait_for_vacuum_stats(
+    tab_all_frozen_pages_count  => $vm_new_visible_frozen_pages,
+    tab_all_visible_pages_count => 0,
+    run_vacuum                  => 1,
+);
+
+ok($updated,
+   'vacuum stats updated after vacuuming the all-updated table (vm_new_visible_frozen_pages advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics to update after $timeout seconds during vacuum";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages > $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages has increased');
+ok($rev_all_frozen_pages == $rev_all_frozen_pages_prev, 'table rev_all_frozen_pages stay the same');
+ok($rev_all_visible_pages == $rev_all_visible_pages_prev, 'table rev_all_visible_pages stay the same');
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Cleanup
+#------------------------------------------------------------------------------
+
+$node->safe_psql('postgres', q{
+    DROP DATABASE statistic_vacuum_database_regression;
+});
+
+$node->stop;
+done_testing();
diff --git a/contrib/ext_vacuum_statistics/t/054_vacuum_extending_gucs_test.pl b/contrib/ext_vacuum_statistics/t/054_vacuum_extending_gucs_test.pl
new file mode 100644
index 00000000000..a195249842b
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/t/054_vacuum_extending_gucs_test.pl
@@ -0,0 +1,279 @@
+# Copyright (c) 2025 PostgreSQL Global Development Group
+#
+# Test GUC parameters for ext_vacuum_statistics extension:
+#   vacuum_statistics.enabled
+#   vacuum_statistics.object_types (all, databases, relations)
+#   vacuum_statistics.track_relations (all, system, user)
+#   vacuum_statistics.track_databases_from_list, add/remove_track_database
+#   add/remove_track_database, add/remove_track_relation, track_*_from_list
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+ 
+use Test::More;
+
+#------------------------------------------------------------------------------
+# Test cluster setup
+#------------------------------------------------------------------------------
+
+my $node = PostgreSQL::Test::Cluster->new('ext_stat_vacuum_gucs');
+$node->init;
+
+$node->append_conf('postgresql.conf', q{
+    shared_preload_libraries = 'ext_vacuum_statistics'
+    log_min_messages = notice
+});
+
+$node->start;
+
+#------------------------------------------------------------------------------
+# Database creation and initialization
+#------------------------------------------------------------------------------
+
+$node->safe_psql('postgres', q{
+    CREATE DATABASE statistic_vacuum_gucs;
+});
+
+my $dbname = 'statistic_vacuum_gucs';
+
+$node->safe_psql($dbname, q{
+    CREATE EXTENSION ext_vacuum_statistics;
+    CREATE TABLE guc_test (x int PRIMARY KEY)
+        WITH (autovacuum_enabled = off);
+    INSERT INTO guc_test SELECT x FROM generate_series(1, 100) AS g(x);
+    ANALYZE guc_test;
+});
+
+# Get OIDs for filtering tests
+my $dboid = $node->safe_psql($dbname, q{SELECT oid FROM pg_database WHERE datname = current_database()});
+my $reloid = $node->safe_psql($dbname, q{SELECT oid FROM pg_class WHERE relname = 'guc_test'});
+
+#------------------------------------------------------------------------------
+# Reset stats and run vacuum (all in one session so GUCs persist)
+#------------------------------------------------------------------------------
+
+sub reset_and_vacuum {
+    my ($db, $table, $opts) = @_;
+    $table ||= 'guc_test';
+    my $gucs = $opts && $opts->{gucs} ? $opts->{gucs} : [];
+    my $modify = $opts && $opts->{modify};
+    my $extra = $opts && $opts->{extra_vacuum} ? $opts->{extra_vacuum} : [];
+    $extra = [$extra] unless ref $extra eq 'ARRAY';
+    my $sql = join("\n", (map { "SET $_;" } @$gucs),
+        "SELECT ext_vacuum_statistics.vacuum_statistics_reset();",
+        $modify ? (
+            "TRUNCATE $table;",
+            "INSERT INTO $table SELECT x FROM generate_series(1, 100) AS g(x);",
+            "DELETE FROM $table;",
+        ) : (),
+        "VACUUM $table;",
+        (map { "VACUUM $_;" } @$extra),
+        # Make pending stats visible to subsequent sessions without sleeping.
+        "SELECT pg_stat_force_next_flush();");
+    $node->safe_psql($db, $sql);
+}
+
+#------------------------------------------------------------------------------
+# Test 1: vacuum_statistics.enabled
+#------------------------------------------------------------------------------
+subtest 'vacuum_statistics.enabled' => sub {
+    reset_and_vacuum($dbname);
+
+    # Default: enabled - should have stats
+    my $count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    ok($count > 0, 'stats collected when enabled');
+
+    # Disable, reset and vacuum in same session.  Assert not only that the
+    # row count is zero, but that the specific counters remain zero: a stray
+    # row with zero counters would otherwise pass a bare COUNT(*)=0 check.
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ['vacuum_statistics.enabled = off'] });
+
+    $count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    is($count, 0, 'no rows when disabled');
+
+    my $sums = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(total_blks_read), 0)
+             + COALESCE(SUM(total_blks_dirtied), 0)
+             + COALESCE(SUM(pages_scanned), 0)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($sums, '0', 'no counters accumulated when disabled');
+};
+
+#------------------------------------------------------------------------------
+# Test 2: vacuum_statistics.object_types (databases only, relations only)
+#------------------------------------------------------------------------------
+subtest 'vacuum_statistics.object_types' => sub {
+    # track only db stats, no relation stats
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => ["vacuum_statistics.object_types = 'databases'"],
+        modify => 1,
+    });
+    my $db_has_dbs = $node->safe_psql($dbname,
+        "SELECT COALESCE(SUM(db_blks_hit), 0) FROM ext_vacuum_statistics.pg_stats_vacuum_database WHERE dboid = $dboid");
+    my $rel_dbs = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    is($rel_dbs, 0, 'track=databases: no relation stats');
+    ok($db_has_dbs > 0, 'track=databases: database stats collected');
+
+    # track only relation stats, no db stats
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => ["vacuum_statistics.object_types = 'relations'"],
+        modify => 1,
+    });
+    my $db_has_rels = $node->safe_psql($dbname,
+        "SELECT COALESCE(SUM(db_blks_hit), 0) > 0 FROM ext_vacuum_statistics.pg_stats_vacuum_database WHERE dboid = $dboid");
+    my $rel_rels = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    ok($rel_rels > 0, 'track=relations: relation stats collected');
+    is($db_has_rels, 'f', 'track=relations: no database stats');
+};
+
+#------------------------------------------------------------------------------
+# Test 3: vacuum_statistics.track_relations (system, user)
+#------------------------------------------------------------------------------
+subtest 'vacuum_statistics.track_relations' => sub {
+    # track_relations - only user tables
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => [
+            "vacuum_statistics.object_types = 'relations'",
+            "vacuum_statistics.track_relations = 'user'",
+        ],
+        extra_vacuum => ['pg_class'],
+    });
+
+    my $user_rel = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    my $sys_rel = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'pg_class'");
+    ok($user_rel > 0, 'track_relations=user: user table stats collected');
+    is($sys_rel, 0, 'track_relations=user: system table stats not collected');
+
+    # track_relations - only system tables
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => [
+            "vacuum_statistics.object_types = 'relations'",
+            "vacuum_statistics.track_relations = 'system'",
+        ],
+        extra_vacuum => ['pg_class'],
+    });
+
+    $user_rel = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    $sys_rel = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'pg_class'");
+    is($user_rel, 0, 'track_relations=system: user table stats not collected');
+    ok($sys_rel > 0, 'track_relations=system: system table stats collected');
+};
+
+#------------------------------------------------------------------------------
+# Test 4: track_databases (via add/remove_track_database)
+#------------------------------------------------------------------------------
+subtest 'track_databases (add/remove)' => sub {
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.remove_track_database($dboid)");
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.add_track_database($dboid)");
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ["vacuum_statistics.track_databases_from_list = on"], modify => 1 });
+
+    my $rel_count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    ok($rel_count > 0, 'db in list: stats collected');
+
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.remove_track_database($dboid)");
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ["vacuum_statistics.track_databases_from_list = on"], modify => 1 });
+
+    $rel_count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    is($rel_count, 0, 'db removed from list: no stats');
+};
+
+#------------------------------------------------------------------------------
+# Test 5: track_relations (via add/remove_track_relation)
+#------------------------------------------------------------------------------
+subtest 'track_relations (add/remove)' => sub {
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.remove_track_relation($dboid, $reloid)");
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.add_track_relation($dboid, $reloid)");
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ["vacuum_statistics.track_relations_from_list = on"], modify => 1 });
+
+    my $rel_count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    ok($rel_count > 0, 'table in list: stats collected');
+
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.remove_track_relation($dboid, $reloid)");
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ["vacuum_statistics.track_relations_from_list = on"], modify => 1 });
+
+    $rel_count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    is($rel_count, 0, 'table removed from list: no stats');
+};
+
+#------------------------------------------------------------------------------
+# Test 6: vacuum_statistics.collect - per-category gating
+#
+# With collect='wal' only wal_* counters must advance; buffer, timing, and
+# general categories must stay at zero.  With collect='buffers' the inverse
+# holds.  Unknown tokens must be rejected by the check-hook.
+#------------------------------------------------------------------------------
+subtest 'vacuum_statistics.collect' => sub {
+    # wal-only: WAL counters should accumulate, buffers/timing/general should not.
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => ["vacuum_statistics.collect = 'wal'"],
+        modify => 1,
+    });
+
+    my $wal = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(wal_records), 0) > 0
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($wal, 't', "collect='wal': wal_records accumulated");
+
+    my $other = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(total_blks_read), 0)
+             + COALESCE(SUM(total_blks_hit), 0)
+             + COALESCE(SUM(total_time), 0)
+             + COALESCE(SUM(tuples_deleted), 0)
+             + COALESCE(SUM(pages_scanned), 0)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($other, '0',
+        "collect='wal': buffer/timing/general counters not accumulated");
+
+    # buffers-only: buffer counters should advance, WAL should not.
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => ["vacuum_statistics.collect = 'buffers'"],
+        modify => 1,
+    });
+
+    my $buf = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(total_blks_read), 0)
+             + COALESCE(SUM(total_blks_hit), 0) > 0
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($buf, 't', "collect='buffers': buffer counters accumulated");
+
+    my $wal_off = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(wal_records), 0)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($wal_off, '0',
+        "collect='buffers': WAL counters not accumulated");
+
+    # Unknown category must be rejected by the check-hook.
+    my ($ret, $stdout, $stderr) = $node->psql($dbname,
+        "SET vacuum_statistics.collect = 'nope'");
+    isnt($ret, 0, "collect='nope': rejected by check-hook");
+    like($stderr, qr/Unrecognized category "nope"/,
+        "collect='nope': errdetail names the offending token");
+};
+
+$node->stop;
+
+done_testing();
diff --git a/contrib/ext_vacuum_statistics/vacuum_statistics.c b/contrib/ext_vacuum_statistics/vacuum_statistics.c
new file mode 100644
index 00000000000..144b9bcb814
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/vacuum_statistics.c
@@ -0,0 +1,1387 @@
+/*
+ * ext_vacuum_statistics - Extended vacuum statistics for PostgreSQL
+ *
+ * This module collects detailed vacuum statistics (I/O, WAL, timing, etc.)
+ * at relation and database level by hooking into the vacuum reporting path.
+ * Statistics are stored via pgstat custom statistics. Management of statistics
+ * storage and output functions are implemented in this module.
+ */
+#include "postgres.h"
+
+#include "access/transam.h"
+#include "catalog/catalog.h"
+#include "catalog/objectaccess.h"
+#include "catalog/pg_authid.h"
+#include "catalog/pg_class.h"
+#include "catalog/pg_database.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "pgstat.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "storage/lwlock.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/fmgrprotos.h"
+#include "utils/guc.h"
+#include "utils/hsearch.h"
+#include "utils/lsyscache.h"
+#include "utils/pgstat_kind.h"
+#include "utils/pgstat_internal.h"
+#include "utils/tuplestore.h"
+
+#ifdef PG_MODULE_MAGIC
+PG_MODULE_MAGIC;
+#endif
+
+/* Two kinds: relations (tables/indexes) and database aggregates */
+#define PGSTAT_KIND_EXTVAC_RELATION	24
+#define PGSTAT_KIND_EXTVAC_DB		25
+
+#define SJ_NODENAME		"vacuum_statistics"
+#define EVS_TRACK_FILENAME	"pg_stat/ext_vacuum_statistics_track.oid"
+
+/* Bit flags for evs_track (object_types): 'all', 'databases', 'relations' */
+#define EVS_TRACK_RELATIONS		0x01
+#define EVS_TRACK_DATABASES		0x02
+
+/* Bit flags for evs_track_relations: 'all', 'system', 'user' */
+#define EVS_FILTER_SYSTEM		0x01
+#define EVS_FILTER_USER			0x02
+
+/*
+ * Bit flags for evs_collect_mask. Each category groups counters that can be
+ * accumulated (or skipped) together, letting users reduce overhead at run
+ * time by turning off categories they don't need.
+ */
+#define EVS_COLLECT_BUFFERS		0x1 /* blks_*, blk_*_time */
+#define EVS_COLLECT_WAL			0x2 /* wal_records, wal_fpi, wal_bytes */
+#define EVS_COLLECT_GENERAL		0x4 /* tuples_deleted, pages_*, vm_*,
+									 * wraparound_failsafe_count,
+									 * interrupts_count */
+#define EVS_COLLECT_TIMING		0x8 /* delay_time, total_time */
+#define EVS_COLLECT_ALL			(EVS_COLLECT_BUFFERS | EVS_COLLECT_WAL | \
+								 EVS_COLLECT_GENERAL | EVS_COLLECT_TIMING)
+
+/*  GUCs  */
+static bool evs_enabled = true;
+static char *evs_track = "all"; /* 'all', 'databases', 'relations' */
+static char *evs_track_relations = "all";	/* 'all', 'system', 'user' */
+static int	evs_track_bits = EVS_TRACK_RELATIONS | EVS_TRACK_DATABASES;
+static int	evs_track_relations_bits = EVS_FILTER_SYSTEM | EVS_FILTER_USER;
+static bool evs_track_databases_from_list = false;	/* if true, track only
+													 * databases in list */
+static bool evs_track_relations_from_list = false;	/* if true, track only
+													 * relations in list */
+static char *evs_collect = "all";	/* categories to collect */
+static int	evs_collect_mask = EVS_COLLECT_ALL;
+
+/*  Hook  */
+static set_report_vacuum_hook_type prev_report_vacuum_hook = NULL;
+static object_access_hook_type prev_object_access_hook = NULL;
+static shmem_request_hook_type prev_shmem_request_hook = NULL;
+
+/*  Forward declarations  */
+static void pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
+										  PgStat_VacuumRelationCounts * params);
+static bool evs_oid_in_list(HTAB *hash, Oid oid);
+static void evs_track_hash_ensure_init(void);
+static void evs_track_save_file(void);
+static void evs_track_load_file(void);
+static void evs_drop_access_hook(ObjectAccessType access, Oid classId,
+								 Oid objectId, int subId, void *arg);
+static void evs_shmem_request(void);
+
+/* Hash tables for track_databases and track_relations_list (backend-local) */
+static HTAB *evs_track_databases_hash = NULL;
+static HTAB *evs_track_relations_hash = NULL;
+static bool evs_track_hash_initialized = false;
+
+/*
+ * Named LWLock tranche protecting the on-disk track file and serializing
+ * backend-local reloads/saves across concurrent backends.
+ */
+#define EVS_TRACK_TRANCHE_NAME "ext_vacuum_statistics_track"
+static LWLock *evs_track_lock = NULL;
+
+static inline LWLock *
+evs_get_track_lock(void)
+{
+	if (evs_track_lock == NULL)
+		evs_track_lock = &GetNamedLWLockTranche(EVS_TRACK_TRANCHE_NAME)->lock;
+	return evs_track_lock;
+}
+
+/*
+ * objid encoding for relations: (relid << 2) | (type & 3)
+ */
+#define EXTVAC_OBJID(relid, type) (((uint64) (relid)) << 2 | ((type) & 3))
+
+/* Key for relation tracking: (dboid, reloid).
+ * InvalidOid for dboid means it is a cluster object.
+ */
+typedef struct
+{
+	Oid			dboid;
+	Oid			reloid;
+}			EvsTrackRelKey;
+
+/* Shared memory entry for vacuum stats; one per relation or database. */
+typedef struct PgStatShared_ExtVacEntry
+{
+	PgStatShared_Common header;
+	PgStat_VacuumRelationCounts stats;
+}			PgStatShared_ExtVacEntry;
+
+/* PgStat kind for per-relation vacuum statistics (tables/indexes) */
+static const PgStat_KindInfo extvac_relation_kind_info = {
+	.name = "ext_vacuum_statistics_relation",
+	.fixed_amount = false,
+	.accessed_across_databases = true,
+	.write_to_file = true,
+	.track_entry_count = true,
+	.shared_size = sizeof(PgStatShared_ExtVacEntry),
+	.shared_data_off = offsetof(PgStatShared_ExtVacEntry, stats),
+	.shared_data_len = sizeof(PgStat_VacuumRelationCounts),
+	.pending_size = 0,
+	.flush_pending_cb = NULL,
+};
+
+/* PgStat kind for per-database aggregated vacuum statistics */
+static const PgStat_KindInfo extvac_db_kind_info = {
+	.name = "ext_vacuum_statistics_db",
+	.fixed_amount = false,
+	.accessed_across_databases = true,
+	.write_to_file = true,
+	.track_entry_count = true,
+	.shared_size = sizeof(PgStatShared_ExtVacEntry),
+	.shared_data_off = offsetof(PgStatShared_ExtVacEntry, stats),
+	.shared_data_len = sizeof(PgStat_VacuumRelationCounts),
+	.pending_size = 0,
+	.flush_pending_cb = NULL,
+};
+
+/*
+ * Accumulate a single counter only if its category is enabled in
+ * evs_collect_mask. Parentheses around every argument: the macro is invoked
+ * from expression contexts and with expressions as the destination pointer.
+ */
+#define ACCUM_IF(dst, src, field, cat) \
+	do { \
+		if ((evs_collect_mask) & (cat)) \
+			((dst))->field += ((src))->field; \
+	} while (0)
+
+static inline void
+pgstat_accumulate_common(PgStat_CommonCounts * dst, const PgStat_CommonCounts * src)
+{
+	ACCUM_IF(dst, src, total_blks_read, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, total_blks_hit, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, total_blks_dirtied, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, total_blks_written, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, blks_fetched, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, blks_hit, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, blk_read_time, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, blk_write_time, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, delay_time, EVS_COLLECT_TIMING);
+	ACCUM_IF(dst, src, total_time, EVS_COLLECT_TIMING);
+	ACCUM_IF(dst, src, wal_records, EVS_COLLECT_WAL);
+	ACCUM_IF(dst, src, wal_fpi, EVS_COLLECT_WAL);
+	ACCUM_IF(dst, src, wal_bytes, EVS_COLLECT_WAL);
+	ACCUM_IF(dst, src, wraparound_failsafe_count, EVS_COLLECT_GENERAL);
+	ACCUM_IF(dst, src, interrupts_count, EVS_COLLECT_GENERAL);
+	ACCUM_IF(dst, src, tuples_deleted, EVS_COLLECT_GENERAL);
+}
+
+static inline void
+pgstat_accumulate_extvac_stats(PgStat_VacuumRelationCounts * dst,
+							   const PgStat_VacuumRelationCounts * src)
+{
+	if (dst->type == PGSTAT_EXTVAC_INVALID)
+		dst->type = src->type;
+
+	Assert(src->type != PGSTAT_EXTVAC_INVALID && src->type != PGSTAT_EXTVAC_DB);
+	Assert(src->type == dst->type);
+
+	pgstat_accumulate_common(&dst->common, &src->common);
+
+	if (dst->type == PGSTAT_EXTVAC_TABLE &&
+		(evs_collect_mask & EVS_COLLECT_GENERAL) != 0)
+	{
+		dst->table.pages_scanned += src->table.pages_scanned;
+		dst->table.pages_removed += src->table.pages_removed;
+		dst->table.tuples_frozen += src->table.tuples_frozen;
+		dst->table.recently_dead_tuples += src->table.recently_dead_tuples;
+		dst->table.vm_new_frozen_pages += src->table.vm_new_frozen_pages;
+		dst->table.vm_new_visible_pages += src->table.vm_new_visible_pages;
+		dst->table.vm_new_visible_frozen_pages += src->table.vm_new_visible_frozen_pages;
+		dst->table.missed_dead_pages += src->table.missed_dead_pages;
+		dst->table.missed_dead_tuples += src->table.missed_dead_tuples;
+		dst->table.index_vacuum_count += src->table.index_vacuum_count;
+	}
+	else if (dst->type == PGSTAT_EXTVAC_INDEX &&
+			 (evs_collect_mask & EVS_COLLECT_GENERAL) != 0)
+	{
+		dst->index.pages_deleted += src->index.pages_deleted;
+	}
+}
+
+/*
+ * GUC check hooks: validate the string and compute the bitmask into *extra.
+ * Rejecting unknown values here prevents silent fall-through to "all".
+ */
+static bool
+evs_track_check_hook(char **newval, void **extra, GucSource source)
+{
+	int		   *bits;
+
+	if (*newval == NULL)
+		return false;
+
+	bits = (int *) guc_malloc(LOG, sizeof(int));
+	if (!bits)
+		return false;
+
+	if (strcmp(*newval, "all") == 0)
+		*bits = EVS_TRACK_RELATIONS | EVS_TRACK_DATABASES;
+	else if (strcmp(*newval, "databases") == 0)
+		*bits = EVS_TRACK_DATABASES;
+	else if (strcmp(*newval, "relations") == 0)
+		*bits = EVS_TRACK_RELATIONS;
+	else
+	{
+		guc_free(bits);
+		GUC_check_errdetail("Allowed values are \"all\", \"databases\", \"relations\".");
+		return false;
+	}
+	*extra = bits;
+	return true;
+}
+
+static void
+evs_track_assign_hook(const char *newval, void *extra)
+{
+	evs_track_bits = *((int *) extra);
+}
+
+static bool
+evs_track_relations_check_hook(char **newval, void **extra, GucSource source)
+{
+	int		   *bits;
+
+	if (*newval == NULL)
+		return false;
+
+	bits = (int *) guc_malloc(LOG, sizeof(int));
+	if (!bits)
+		return false;
+
+	if (strcmp(*newval, "all") == 0)
+		*bits = EVS_FILTER_SYSTEM | EVS_FILTER_USER;
+	else if (strcmp(*newval, "system") == 0)
+		*bits = EVS_FILTER_SYSTEM;
+	else if (strcmp(*newval, "user") == 0)
+		*bits = EVS_FILTER_USER;
+	else
+	{
+		guc_free(bits);
+		GUC_check_errdetail("Allowed values are \"all\", \"system\", \"user\".");
+		return false;
+	}
+	*extra = bits;
+	return true;
+}
+
+static void
+evs_track_relations_assign_hook(const char *newval, void *extra)
+{
+	evs_track_relations_bits = *((int *) extra);
+}
+
+/*
+ * Check hook for vacuum_statistics.collect.
+ *
+ * Accepts a comma- or whitespace-separated list of category names
+ * (buffers, wal, general, timing) or the shorthand "all".  Computes the
+ * matching bitmask once and stashes it in *extra; the assign hook just
+ * copies it into evs_collect_mask.  Unknown tokens are rejected so the
+ * setting cannot silently collapse to the "all" default.
+ */
+static bool
+evs_collect_check_hook(char **newval, void **extra, GucSource source)
+{
+	int		   *mask;
+	char	   *copy;
+	char	   *p;
+	char	   *tok;
+	int			accum = 0;
+	bool		saw_all = false;
+
+	if (*newval == NULL)
+		return false;
+
+	mask = (int *) guc_malloc(LOG, sizeof(int));
+	if (!mask)
+		return false;
+
+	/* Empty string means "all", matching the default behavior. */
+	if ((*newval)[0] == '\0')
+	{
+		*mask = EVS_COLLECT_ALL;
+		*extra = mask;
+		return true;
+	}
+
+	copy = pstrdup(*newval);
+	for (p = copy; (tok = strtok(p, " \t,")) != NULL; p = NULL)
+	{
+		if (pg_strcasecmp(tok, "all") == 0)
+			saw_all = true;
+		else if (pg_strcasecmp(tok, "buffers") == 0)
+			accum |= EVS_COLLECT_BUFFERS;
+		else if (pg_strcasecmp(tok, "wal") == 0)
+			accum |= EVS_COLLECT_WAL;
+		else if (pg_strcasecmp(tok, "general") == 0)
+			accum |= EVS_COLLECT_GENERAL;
+		else if (pg_strcasecmp(tok, "timing") == 0)
+			accum |= EVS_COLLECT_TIMING;
+		else
+		{
+			/*
+			 * GUC_check_errdetail formats the message immediately, but tok
+			 * points into copy; emit the detail first, then free the
+			 * scratch buffer so the formatted string is already stashed in
+			 * GUC_check_errdetail_string.
+			 */
+			GUC_check_errdetail("Unrecognized category \"%s\" in vacuum_statistics.collect; "
+								"allowed values are \"all\", \"buffers\", \"wal\", \"general\", \"timing\".",
+								tok);
+			pfree(copy);
+			guc_free(mask);
+			return false;
+		}
+	}
+	pfree(copy);
+
+	*mask = saw_all ? EVS_COLLECT_ALL : accum;
+	if (*mask == 0)
+		*mask = EVS_COLLECT_ALL;
+	*extra = mask;
+	return true;
+}
+
+static void
+evs_collect_assign_hook(const char *newval, void *extra)
+{
+	evs_collect_mask = *((int *) extra);
+}
+
+void
+_PG_init(void)
+{
+	if (!process_shared_preload_libraries_in_progress)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics module could be loaded only on startup."),
+				 errdetail("Add 'ext_vacuum_statistics' into the shared_preload_libraries list.")));
+
+	DefineCustomBoolVariable("vacuum_statistics.enabled",
+							 "Enable extended vacuum statistics collection.",
+							 NULL, &evs_enabled, true,
+							 PGC_SUSET, 0, NULL, NULL, NULL);
+
+	DefineCustomStringVariable("vacuum_statistics.object_types",
+							   "Object types for statistics: 'all', 'databases', 'relations'.",
+							   NULL, &evs_track, "all",
+							   PGC_SUSET, 0,
+							   evs_track_check_hook,
+							   evs_track_assign_hook, NULL);
+
+	DefineCustomStringVariable("vacuum_statistics.track_relations",
+							   "When tracking relations: 'all', 'system', 'user'.",
+							   NULL, &evs_track_relations, "all",
+							   PGC_SUSET, 0,
+							   evs_track_relations_check_hook,
+							   evs_track_relations_assign_hook, NULL);
+
+	DefineCustomBoolVariable("vacuum_statistics.track_databases_from_list",
+							 "If true, track only databases added via add_track_database.",
+							 NULL, &evs_track_databases_from_list, false,
+							 PGC_SUSET, 0, NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("vacuum_statistics.track_relations_from_list",
+							 "If true, track only relations added via add_track_relation.",
+							 NULL, &evs_track_relations_from_list, false,
+							 PGC_SUSET, 0, NULL, NULL, NULL);
+
+	DefineCustomStringVariable("vacuum_statistics.collect",
+							   "Statistics categories to collect.",
+							   "Comma- or whitespace-separated list of: "
+							   "\"buffers\", \"wal\", \"general\", \"timing\"; "
+							   "or \"all\" for every category (default).",
+							   &evs_collect, "all",
+							   PGC_SUSET, 0,
+							   evs_collect_check_hook,
+							   evs_collect_assign_hook, NULL);
+
+	MarkGUCPrefixReserved(SJ_NODENAME);
+
+	pgstat_register_kind(PGSTAT_KIND_EXTVAC_RELATION, &extvac_relation_kind_info);
+	pgstat_register_kind(PGSTAT_KIND_EXTVAC_DB, &extvac_db_kind_info);
+
+	prev_shmem_request_hook = shmem_request_hook;
+	shmem_request_hook = evs_shmem_request;
+
+	prev_report_vacuum_hook = set_report_vacuum_hook;
+	set_report_vacuum_hook = pgstat_report_vacuum_extstats;
+
+	prev_object_access_hook = object_access_hook;
+	object_access_hook = evs_drop_access_hook;
+}
+
+static void
+evs_shmem_request(void)
+{
+	if (prev_shmem_request_hook)
+		prev_shmem_request_hook();
+
+	RequestNamedLWLockTranche(EVS_TRACK_TRANCHE_NAME, 1);
+}
+
+/*
+ * Object access hook: remove dropped objects from track lists.
+ */
+static void
+evs_drop_access_hook(ObjectAccessType access, Oid classId,
+					 Oid objectId, int subId, void *arg)
+{
+	if (prev_object_access_hook)
+		(*prev_object_access_hook) (access, classId, objectId, subId, arg);
+
+	if (access == OAT_DROP)
+	{
+		if (classId == RelationRelationId && subId == 0)
+		{
+			char		relkind = get_rel_relkind(objectId);
+			EvsTrackRelKey key;
+			bool		found;
+
+			if (relkind == RELKIND_RELATION || relkind == RELKIND_INDEX)
+			{
+				LWLock	   *lock = evs_get_track_lock();
+
+				LWLockAcquire(lock, LW_EXCLUSIVE);
+				evs_track_hash_ensure_init();
+				key.dboid = MyDatabaseId;
+				key.reloid = objectId;
+				hash_search(evs_track_relations_hash, &key, HASH_REMOVE, &found);
+				key.dboid = InvalidOid;
+				hash_search(evs_track_relations_hash, &key, HASH_REMOVE, &found);
+				evs_track_save_file();
+				LWLockRelease(lock);
+			}
+		}
+
+		if (classId == DatabaseRelationId && objectId != InvalidOid)
+		{
+			LWLock	   *lock = evs_get_track_lock();
+			bool		found;
+
+			LWLockAcquire(lock, LW_EXCLUSIVE);
+			evs_track_hash_ensure_init();
+			hash_search(evs_track_databases_hash, &objectId, HASH_REMOVE, &found);
+			evs_track_save_file();
+			LWLockRelease(lock);
+		}
+	}
+}
+
+/*
+ * Storage of track lists in a separate file.
+ *
+ * Stores the lists of database OIDs and (dboid, reloid) pairs used for
+ * selective tracking when track_databases_from_list or track_relations_from_list
+ * is enabled.
+ * Data stores in pg_stat/ext_vacuum_statistics_track.oid
+ */
+/*
+ * Initialize the backend-local tracking hashes and load their contents
+ * from the on-disk file.
+ *
+ * The hashes are per-backend, so no lock is needed to protect them from
+ * other processes; however, another backend may be concurrently rewriting
+ * the track file, so we take a shared lock for the file read.
+ */
+static void
+evs_track_hash_ensure_init(void)
+{
+	HASHCTL		ctl;
+	LWLock	   *lock;
+	bool		need_load;
+
+	if (evs_track_hash_initialized)
+		return;
+
+	lock = evs_get_track_lock();
+
+	if (evs_track_databases_hash == NULL)
+	{
+		memset(&ctl, 0, sizeof(ctl));
+		ctl.keysize = sizeof(Oid);
+		ctl.entrysize = sizeof(Oid);
+		ctl.hcxt = TopMemoryContext;
+		evs_track_databases_hash =
+			hash_create("ext_vacuum_statistics track databases",
+						64, &ctl, HASH_ELEM | HASH_BLOBS);
+	}
+
+	if (evs_track_relations_hash == NULL)
+	{
+		memset(&ctl, 0, sizeof(ctl));
+		ctl.keysize = sizeof(EvsTrackRelKey);
+		ctl.entrysize = sizeof(EvsTrackRelKey);
+		ctl.hcxt = TopMemoryContext;
+		evs_track_relations_hash =
+			hash_create("ext_vacuum_statistics track relations",
+						64, &ctl, HASH_ELEM | HASH_BLOBS);
+	}
+
+	need_load = !LWLockHeldByMe(lock);
+	if (need_load)
+		LWLockAcquire(lock, LW_SHARED);
+	PG_TRY();
+	{
+		evs_track_load_file();
+		evs_track_hash_initialized = true;
+	}
+	PG_FINALLY();
+	{
+		if (need_load)
+			LWLockRelease(lock);
+	}
+	PG_END_TRY();
+}
+
+/*
+ * Load track lists from disk into the backend-local hashes.
+ *
+ * Caller must hold evs_track_lock at least in shared mode, since the file
+ * may be concurrently rewritten by another backend.
+ */
+static void
+evs_track_load_file(void)
+{
+	char		path[MAXPGPATH];
+	FILE	   *fp;
+	char		buf[MAXPGPATH];
+	bool		in_relations = false;
+	Oid			oid;
+	EvsTrackRelKey key;
+	bool		found;
+
+	if (!DataDir || DataDir[0] == '\0' ||
+		!evs_track_databases_hash || !evs_track_relations_hash)
+		return;
+
+	snprintf(path, sizeof(path), "%s/%s", DataDir, EVS_TRACK_FILENAME);
+	fp = AllocateFile(path, "r");
+	if (!fp)
+	{
+		if (errno != ENOENT)
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open track file \"%s\": %m", path)));
+		return;
+	}
+
+	PG_TRY();
+	{
+		while (fgets(buf, sizeof(buf), fp))
+		{
+			size_t		len = strlen(buf);
+
+			/* Reject unterminated lines (longer than buffer) as corruption. */
+			if (len > 0 && buf[len - 1] != '\n' && !feof(fp))
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("line too long in track file \"%s\"", path)));
+
+			if (strncmp(buf, "[databases]", 11) == 0)
+			{
+				in_relations = false;
+				continue;
+			}
+			if (strncmp(buf, "[relations]", 11) == 0)
+			{
+				in_relations = true;
+				continue;
+			}
+			if (in_relations)
+			{
+				if (sscanf(buf, "%u %u", &key.dboid, &key.reloid) == 2)
+					hash_search(evs_track_relations_hash, &key, HASH_ENTER, &found);
+				else if (sscanf(buf, "%u", &oid) == 1)
+				{
+					key.dboid = InvalidOid;
+					key.reloid = oid;
+					hash_search(evs_track_relations_hash, &key, HASH_ENTER, &found);
+				}
+			}
+			else if (sscanf(buf, "%u", &oid) == 1)
+				hash_search(evs_track_databases_hash, &oid, HASH_ENTER, &found);
+		}
+
+		if (ferror(fp))
+			ereport(ERROR,
+					(errcode_for_file_access(),
+					 errmsg("could not read track file \"%s\": %m", path)));
+	}
+	PG_FINALLY();
+	{
+		FreeFile(fp);
+	}
+	PG_END_TRY();
+}
+
+/*
+ * Atomically rewrite the track file. Caller must hold evs_track_lock
+ * in exclusive mode.
+ */
+static void
+evs_track_save_file(void)
+{
+	char		path[MAXPGPATH];
+	char		tmppath[MAXPGPATH];
+	FILE	   *fp;
+	HASH_SEQ_STATUS status;
+	Oid		   *entry;
+	EvsTrackRelKey *rel_entry;
+	bool		failed = false;
+
+	if (!DataDir || DataDir[0] == '\0' ||
+		!evs_track_databases_hash || !evs_track_relations_hash)
+		return;
+
+	snprintf(path, sizeof(path), "%s/%s", DataDir, EVS_TRACK_FILENAME);
+	snprintf(tmppath, sizeof(tmppath), "%s.tmp", path);
+
+	fp = AllocateFile(tmppath, PG_BINARY_W);
+	if (!fp)
+	{
+		ereport(LOG,
+				(errcode_for_file_access(),
+				 errmsg("could not create track file \"%s\": %m", tmppath)));
+		return;
+	}
+
+	PG_TRY();
+	{
+		if (fputs("[databases]\n", fp) == EOF)
+			failed = true;
+
+		if (!failed)
+		{
+			hash_seq_init(&status, evs_track_databases_hash);
+			while ((entry = (Oid *) hash_seq_search(&status)) != NULL)
+			{
+				if (fprintf(fp, "%u\n", *entry) < 0)
+				{
+					hash_seq_term(&status);
+					failed = true;
+					break;
+				}
+			}
+		}
+
+		if (!failed && fputs("[relations]\n", fp) == EOF)
+			failed = true;
+
+		if (!failed)
+		{
+			hash_seq_init(&status, evs_track_relations_hash);
+			while ((rel_entry = (EvsTrackRelKey *) hash_seq_search(&status)) != NULL)
+			{
+				int			rc;
+
+				if (OidIsValid(rel_entry->dboid))
+					rc = fprintf(fp, "%u %u\n", rel_entry->dboid, rel_entry->reloid);
+				else
+					rc = fprintf(fp, "0 %u\n", rel_entry->reloid);
+				if (rc < 0)
+				{
+					hash_seq_term(&status);
+					failed = true;
+					break;
+				}
+			}
+		}
+
+		if (!failed && fflush(fp) != 0)
+			failed = true;
+
+		if (!failed)
+		{
+			int			fd = fileno(fp);
+
+			if (fd >= 0 && pg_fsync(fd) != 0)
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not fsync track file \"%s\": %m",
+								tmppath)));
+		}
+	}
+	PG_CATCH();
+	{
+		FreeFile(fp);
+		(void) unlink(tmppath);
+		PG_RE_THROW();
+	}
+	PG_END_TRY();
+
+	if (FreeFile(fp) != 0)
+	{
+		ereport(LOG,
+				(errcode_for_file_access(),
+				 errmsg("could not close track file \"%s\": %m", tmppath)));
+		failed = true;
+	}
+
+	if (failed)
+	{
+		ereport(LOG,
+				(errcode_for_file_access(),
+				 errmsg("could not write track file \"%s\": %m", tmppath)));
+		if (unlink(tmppath) != 0 && errno != ENOENT)
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not unlink \"%s\": %m", tmppath)));
+		return;
+	}
+
+	if (durable_rename(tmppath, path, LOG) != 0)
+	{
+		if (unlink(tmppath) != 0 && errno != ENOENT)
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not unlink \"%s\": %m", tmppath)));
+	}
+}
+
+/*
+ * Check if OID is in the given hash
+ */
+static bool
+evs_oid_in_list(HTAB *hash, Oid oid)
+{
+	if (!hash)
+		return false;
+	if (hash_get_num_entries(hash) == 0)
+		return false;
+	return hash_search(hash, &oid, HASH_FIND, NULL) != NULL;
+}
+
+/*
+ * Check if (dboid, relid) is in track_relations list.
+ */
+static bool
+evs_rel_in_list(Oid dboid, Oid relid)
+{
+	EvsTrackRelKey key;
+
+	if (!evs_track_relations_hash)
+		return false;
+	if (hash_get_num_entries(evs_track_relations_hash) == 0)
+		return false;
+	key.dboid = dboid;
+	key.reloid = relid;
+	if (hash_search(evs_track_relations_hash, &key, HASH_FIND, NULL) != NULL)
+		return true;
+	key.dboid = InvalidOid;
+	return hash_search(evs_track_relations_hash, &key, HASH_FIND, NULL) != NULL;
+}
+
+/*
+ * Decide whether to track statistics for relations.
+ * Relation is tracked if it is in the track list or a special filter is enabled.
+ */
+static bool
+evs_should_track_relation_statistics(Oid dboid, Oid relid)
+{
+	evs_track_hash_ensure_init();
+
+	if (evs_track_databases_from_list &&
+		!evs_oid_in_list(evs_track_databases_hash, dboid))
+		return false;
+	if (evs_track_relations_from_list &&
+		!(evs_rel_in_list(dboid, relid) || evs_rel_in_list(InvalidOid, relid)))
+		return false;
+
+	if ((evs_track_bits & EVS_TRACK_RELATIONS) == 0)
+		return false;			/* database-only mode */
+	if (evs_track_relations_bits == EVS_FILTER_SYSTEM)
+		return IsCatalogRelationOid(relid);
+	if (evs_track_relations_bits == EVS_FILTER_USER)
+		return !IsCatalogRelationOid(relid);
+	return true;
+}
+
+/*
+ * Decide whether to track statistics for databases.
+ * Database statistics is tracked if it is in the track list or a special filter is enabled.
+ */
+static bool
+evs_should_track_database_statistics(Oid dboid)
+{
+	evs_track_hash_ensure_init();
+
+	if (evs_track_databases_from_list &&
+		!evs_oid_in_list(evs_track_databases_hash, dboid))
+		return false;
+	if ((evs_track_bits & EVS_TRACK_DATABASES) == 0)
+		return false;			/* relations-only mode */
+	if (evs_track_bits == EVS_TRACK_DATABASES)
+		return true;			/* databases-only, accumulate to db */
+	return true;
+}
+
+
+/* Accumulate common counts for database-level stats. */
+static inline void
+pgstat_accumulate_common_for_db(PgStat_CommonCounts * dst,
+								const PgStat_CommonCounts * src)
+{
+	pgstat_accumulate_common(dst, src);
+}
+
+/*
+ * Store incoming vacuum stats into pgstat custom statistics.
+ * store_relation: create/update per-relation entry
+ * store_db: accumulate into database-level entry (dboid, objid=0).
+ * Uses pgstat_get_entry_ref_locked and pgstat_accumulate_* for atomic updates.
+ */
+static void
+extvac_store(Oid dboid, Oid relid, int type,
+			 PgStat_VacuumRelationCounts * params,
+			 bool store_relation, bool store_db)
+{
+	PgStat_EntryRef *entry_ref;
+	PgStatShared_ExtVacEntry *shared;
+	uint64		objid;
+
+	if (!evs_enabled)
+		return;
+
+	if (store_relation)
+	{
+		objid = EXTVAC_OBJID(relid, type);
+		entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_EXTVAC_RELATION, dboid, objid, false);
+		if (entry_ref)
+		{
+			shared = (PgStatShared_ExtVacEntry *) entry_ref->shared_stats;
+			if (shared->stats.type == PGSTAT_EXTVAC_INVALID)
+			{
+				memset(&shared->stats, 0, sizeof(shared->stats));
+				shared->stats.type = params->type;
+			}
+			pgstat_accumulate_extvac_stats(&shared->stats, params);
+			pgstat_unlock_entry(entry_ref);
+		}
+	}
+
+	if (store_db)
+	{
+		entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_EXTVAC_DB, dboid, InvalidOid, false);
+		if (entry_ref)
+		{
+			shared = (PgStatShared_ExtVacEntry *) entry_ref->shared_stats;
+			if (shared->stats.type == PGSTAT_EXTVAC_INVALID)
+			{
+				memset(&shared->stats, 0, sizeof(shared->stats));
+				shared->stats.type = PGSTAT_EXTVAC_DB;
+			}
+			pgstat_accumulate_common_for_db(&shared->stats.common, &params->common);
+			pgstat_unlock_entry(entry_ref);
+		}
+	}
+}
+
+/*
+ * Vacuum report hook: called when vacuum finishes. Filters by track settings,
+ * stores stats per-relation and/or per-database, then chains to previous hook.
+ */
+static void
+pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
+							  PgStat_VacuumRelationCounts * params)
+{
+	Oid			dboid = shared ? InvalidOid : MyDatabaseId;
+	bool		store_relation;
+	bool		store_db;
+
+	if (evs_enabled)
+	{
+		store_relation = evs_should_track_relation_statistics(dboid, tableoid);
+		store_db = evs_should_track_database_statistics(dboid);
+
+		if (store_relation || store_db)
+			extvac_store(dboid, tableoid, params->type, params, store_relation, store_db);
+	}
+	if (prev_report_vacuum_hook)
+		prev_report_vacuum_hook(tableoid, shared, params);
+}
+
+/* Reset statistics for a single relation entry. */
+static bool
+extvac_reset_by_relid(Oid dboid, Oid relid, int type)
+{
+	uint64		objid = EXTVAC_OBJID(relid, type);
+
+	pgstat_reset_entry(PGSTAT_KIND_EXTVAC_RELATION, dboid, objid, 0);
+	return true;
+}
+
+/* Callback for pgstat_reset_matching_entries: match relation entries for given db */
+static bool
+match_extvac_relations_for_db(PgStatShared_HashEntry *entry, Datum match_data)
+{
+	return entry->key.kind == PGSTAT_KIND_EXTVAC_RELATION &&
+		entry->key.dboid == DatumGetObjectId(match_data);
+}
+
+/*
+ * Reset statistics for a database (aggregate entry) and all its relations.
+ */
+static int64
+extvac_database_reset(Oid dboid)
+{
+	pgstat_reset_matching_entries(match_extvac_relations_for_db,
+								  ObjectIdGetDatum(dboid), 0);
+	pgstat_reset_entry(PGSTAT_KIND_EXTVAC_DB, dboid, 0, 0);
+	return 1;
+}
+
+/* Reset all vacuum statistics (both relation and database entries). */
+static int64
+extvac_stat_reset(void)
+{
+	pgstat_reset_of_kind(PGSTAT_KIND_EXTVAC_RELATION);
+	pgstat_reset_of_kind(PGSTAT_KIND_EXTVAC_DB);
+	return 0;					/* count not available */
+}
+
+PG_FUNCTION_INFO_V1(vacuum_statistics_reset);
+PG_FUNCTION_INFO_V1(extvac_shared_memory_size);
+PG_FUNCTION_INFO_V1(extvac_reset_entry);
+PG_FUNCTION_INFO_V1(extvac_reset_db_entry);
+
+Datum
+vacuum_statistics_reset(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_INT64(extvac_stat_reset());
+}
+
+Datum
+extvac_reset_entry(PG_FUNCTION_ARGS)
+{
+	Oid			dboid = PG_GETARG_OID(0);
+	Oid			relid = PG_GETARG_OID(1);
+	int			type = PG_GETARG_INT32(2);
+
+	PG_RETURN_BOOL(extvac_reset_by_relid(dboid, relid, type));
+}
+
+Datum
+extvac_reset_db_entry(PG_FUNCTION_ARGS)
+{
+	Oid			dboid = PG_GETARG_OID(0);
+
+	PG_RETURN_INT64(extvac_database_reset(dboid));
+}
+
+/*
+ * Return total shared memory in bytes used by the extension for vacuum stats.
+ * Used for monitoring and capacity planning: memory grows with the number of
+ * tracked relations and databases.
+ */
+Datum
+extvac_shared_memory_size(PG_FUNCTION_ARGS)
+{
+	uint64		rel_count;
+	uint64		db_count;
+	uint64		total;
+	size_t		entry_size = sizeof(PgStatShared_ExtVacEntry);
+
+	rel_count = pgstat_get_entry_count(PGSTAT_KIND_EXTVAC_RELATION);
+	db_count = pgstat_get_entry_count(PGSTAT_KIND_EXTVAC_DB);
+	total = rel_count + db_count;
+
+	PG_RETURN_INT64((int64) (total * entry_size));
+}
+
+/*
+ * Track list management: add/remove database or relation OIDs.
+ * Changes are persisted to pg_stat/ext_vacuum_statistics_track.oid.
+ */
+
+PG_FUNCTION_INFO_V1(evs_add_track_database);
+PG_FUNCTION_INFO_V1(evs_remove_track_database);
+PG_FUNCTION_INFO_V1(evs_add_track_relation);
+PG_FUNCTION_INFO_V1(evs_remove_track_relation);
+
+/*
+ * Mutating track-list entry points: require server-wide privilege, since
+ * the underlying lists steer tracking for every backend.
+ */
+static void
+evs_require_track_privilege(const char *funcname)
+{
+	if (!superuser() && !has_privs_of_role(GetUserId(), ROLE_PG_READ_ALL_STATS))
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("permission denied for function %s", funcname),
+				 errhint("Only superusers and members of pg_read_all_stats "
+						 "may change the vacuum statistics track list.")));
+}
+
+Datum
+evs_add_track_database(PG_FUNCTION_ARGS)
+{
+	Oid			oid = PG_GETARG_OID(0);
+	bool		found;
+	LWLock	   *lock;
+
+	evs_require_track_privilege("add_track_database");
+	lock = evs_get_track_lock();
+	LWLockAcquire(lock, LW_EXCLUSIVE);
+	evs_track_hash_ensure_init();
+	hash_search(evs_track_databases_hash, &oid, HASH_ENTER, &found);
+	evs_track_save_file();
+	LWLockRelease(lock);
+	PG_RETURN_BOOL(!found);		/* true if newly added */
+}
+
+Datum
+evs_remove_track_database(PG_FUNCTION_ARGS)
+{
+	Oid			oid = PG_GETARG_OID(0);
+	bool		found;
+	LWLock	   *lock;
+
+	evs_require_track_privilege("remove_track_database");
+	lock = evs_get_track_lock();
+	LWLockAcquire(lock, LW_EXCLUSIVE);
+	evs_track_hash_ensure_init();
+	hash_search(evs_track_databases_hash, &oid, HASH_REMOVE, &found);
+	evs_track_save_file();
+	LWLockRelease(lock);
+	PG_RETURN_BOOL(found);
+}
+
+Datum
+evs_add_track_relation(PG_FUNCTION_ARGS)
+{
+	EvsTrackRelKey key;
+	bool		found;
+	LWLock	   *lock;
+
+	evs_require_track_privilege("add_track_relation");
+	key.dboid = PG_GETARG_OID(0);
+	key.reloid = PG_GETARG_OID(1);
+	lock = evs_get_track_lock();
+	LWLockAcquire(lock, LW_EXCLUSIVE);
+	evs_track_hash_ensure_init();
+	hash_search(evs_track_relations_hash, &key, HASH_ENTER, &found);
+	evs_track_save_file();
+	LWLockRelease(lock);
+	PG_RETURN_BOOL(!found);		/* true if newly added */
+}
+
+Datum
+evs_remove_track_relation(PG_FUNCTION_ARGS)
+{
+	EvsTrackRelKey key;
+	bool		found;
+	LWLock	   *lock;
+
+	evs_require_track_privilege("remove_track_relation");
+	key.dboid = PG_GETARG_OID(0);
+	key.reloid = PG_GETARG_OID(1);
+	lock = evs_get_track_lock();
+	LWLockAcquire(lock, LW_EXCLUSIVE);
+	evs_track_hash_ensure_init();
+	hash_search(evs_track_relations_hash, &key, HASH_REMOVE, &found);
+	evs_track_save_file();
+	LWLockRelease(lock);
+	PG_RETURN_BOOL(found);
+}
+
+/*
+ * Returns the list of database and relation OIDs for which statistics
+ * are collected.
+ */
+PG_FUNCTION_INFO_V1(evs_track_list);
+
+Datum
+evs_track_list(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	TupleDesc	tupdesc;
+	Tuplestorestate *tupstore;
+	MemoryContext per_query_ctx;
+	MemoryContext oldcontext;
+	Datum		values[3];
+	bool		nulls[3] = {false, false, false};
+	HASH_SEQ_STATUS status;
+	Oid		   *entry;
+	EvsTrackRelKey *rel_entry;
+
+	if (!rsinfo || !IsA(rsinfo, ReturnSetInfo))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics: set-valued function called in context that cannot accept a set")));
+	if (!(rsinfo->allowedModes & SFRM_Materialize))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics: materialize mode required")));
+
+	evs_track_hash_ensure_init();
+
+	per_query_ctx = rsinfo->econtext->ecxt_per_query_memory;
+	oldcontext = MemoryContextSwitchTo(per_query_ctx);
+
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "ext_vacuum_statistics: return type must be a row type");
+
+	tupstore = tuplestore_begin_heap(true, false, work_mem);
+	rsinfo->returnMode = SFRM_Materialize;
+	rsinfo->setResult = tupstore;
+	rsinfo->setDesc = tupdesc;
+
+	/* Databases */
+	if (hash_get_num_entries(evs_track_databases_hash) == 0)
+	{
+		values[0] = CStringGetTextDatum("database");
+		nulls[1] = true;
+		nulls[2] = true;
+		tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+		nulls[1] = false;
+		nulls[2] = false;
+	}
+	else
+	{
+		hash_seq_init(&status, evs_track_databases_hash);
+		while ((entry = (Oid *) hash_seq_search(&status)) != NULL)
+		{
+			values[0] = CStringGetTextDatum("database");
+			values[1] = ObjectIdGetDatum(*entry);
+			nulls[2] = true;
+			tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+			nulls[2] = false;
+		}
+	}
+
+	/* Relations */
+	if (hash_get_num_entries(evs_track_relations_hash) == 0)
+	{
+		values[0] = CStringGetTextDatum("relation");
+		nulls[1] = true;
+		nulls[2] = true;
+		tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+		nulls[1] = false;
+		nulls[2] = false;
+	}
+	else
+	{
+		hash_seq_init(&status, evs_track_relations_hash);
+		while ((rel_entry = (EvsTrackRelKey *) hash_seq_search(&status)) != NULL)
+		{
+			values[0] = CStringGetTextDatum("relation");
+			values[1] = ObjectIdGetDatum(rel_entry->dboid);
+			values[2] = ObjectIdGetDatum(rel_entry->reloid);
+			tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+		}
+	}
+
+	MemoryContextSwitchTo(oldcontext);
+
+	return (Datum) 0;
+}
+
+/*
+ * Output vacuum statistics (tables, indexes, or per-database aggregates).
+ */
+#define EXTVAC_COMMON_STAT_COLS 12
+
+static void
+tuplestore_put_common(PgStat_CommonCounts * vacuum_ext,
+					  Datum *values, bool *nulls, int *i)
+{
+	char		buf[256];
+	const int	base PG_USED_FOR_ASSERTS_ONLY = *i;
+
+	values[(*i)++] = Int64GetDatum(vacuum_ext->total_blks_read);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->total_blks_hit);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->total_blks_dirtied);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->total_blks_written);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->wal_records);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->wal_fpi);
+	snprintf(buf, sizeof buf, UINT64_FORMAT, vacuum_ext->wal_bytes);
+	values[(*i)++] = DirectFunctionCall3(numeric_in,
+										 CStringGetDatum(buf),
+										 ObjectIdGetDatum(0),
+										 Int32GetDatum(-1));
+	values[(*i)++] = Float8GetDatum(vacuum_ext->blk_read_time);
+	values[(*i)++] = Float8GetDatum(vacuum_ext->blk_write_time);
+	values[(*i)++] = Float8GetDatum(vacuum_ext->delay_time);
+	values[(*i)++] = Float8GetDatum(vacuum_ext->total_time);
+	values[(*i)++] = Int32GetDatum(vacuum_ext->wraparound_failsafe_count);
+	Assert((*i - base) == EXTVAC_COMMON_STAT_COLS);
+}
+
+#define EXTVAC_HEAP_STAT_COLS	26
+#define EXTVAC_IDX_STAT_COLS	17
+#define EXTVAC_MAX_STAT_COLS	Max(EXTVAC_HEAP_STAT_COLS, EXTVAC_IDX_STAT_COLS)
+
+static void
+tuplestore_put_for_relation(Oid relid, Tuplestorestate *tupstore,
+							TupleDesc tupdesc, PgStat_VacuumRelationCounts * vacuum_ext)
+{
+	Datum		values[EXTVAC_MAX_STAT_COLS];
+	bool		nulls[EXTVAC_MAX_STAT_COLS];
+	int			i = 0;
+
+	memset(nulls, 0, sizeof(nulls));
+	values[i++] = ObjectIdGetDatum(relid);
+
+	tuplestore_put_common(&vacuum_ext->common, values, nulls, &i);
+	values[i++] = Int64GetDatum(vacuum_ext->common.blks_fetched - vacuum_ext->common.blks_hit);
+	values[i++] = Int64GetDatum(vacuum_ext->common.blks_hit);
+
+	if (vacuum_ext->type == PGSTAT_EXTVAC_TABLE)
+	{
+		values[i++] = Int64GetDatum(vacuum_ext->common.tuples_deleted);
+		values[i++] = Int64GetDatum(vacuum_ext->table.pages_scanned);
+		values[i++] = Int64GetDatum(vacuum_ext->table.pages_removed);
+		values[i++] = Int64GetDatum(vacuum_ext->table.vm_new_frozen_pages);
+		values[i++] = Int64GetDatum(vacuum_ext->table.vm_new_visible_pages);
+		values[i++] = Int64GetDatum(vacuum_ext->table.vm_new_visible_frozen_pages);
+		values[i++] = Int64GetDatum(vacuum_ext->table.tuples_frozen);
+		values[i++] = Int64GetDatum(vacuum_ext->table.recently_dead_tuples);
+		values[i++] = Int64GetDatum(vacuum_ext->table.index_vacuum_count);
+		values[i++] = Int64GetDatum(vacuum_ext->table.missed_dead_pages);
+		values[i++] = Int64GetDatum(vacuum_ext->table.missed_dead_tuples);
+	}
+	else if (vacuum_ext->type == PGSTAT_EXTVAC_INDEX)
+	{
+		values[i++] = Int64GetDatum(vacuum_ext->common.tuples_deleted);
+		values[i++] = Int64GetDatum(vacuum_ext->index.pages_deleted);
+	}
+
+	Assert(i == ((vacuum_ext->type == PGSTAT_EXTVAC_TABLE) ? EXTVAC_HEAP_STAT_COLS : EXTVAC_IDX_STAT_COLS));
+	tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+}
+
+static Datum
+pg_stats_vacuum(FunctionCallInfo fcinfo, int type)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	MemoryContext per_query_ctx;
+	MemoryContext oldcontext;
+	Tuplestorestate *tupstore;
+	TupleDesc	tupdesc;
+	Oid			dbid = PG_GETARG_OID(0);
+
+	if (rsinfo == NULL || !IsA(rsinfo, ReturnSetInfo))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics: set-valued function called in context that cannot accept a set")));
+	if (!(rsinfo->allowedModes & SFRM_Materialize))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics: materialize mode required")));
+
+	per_query_ctx = rsinfo->econtext->ecxt_per_query_memory;
+	oldcontext = MemoryContextSwitchTo(per_query_ctx);
+
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "ext_vacuum_statistics: return type must be a row type");
+
+	tupstore = tuplestore_begin_heap(true, false, work_mem);
+	rsinfo->returnMode = SFRM_Materialize;
+	rsinfo->setResult = tupstore;
+	rsinfo->setDesc = tupdesc;
+
+	MemoryContextSwitchTo(oldcontext);
+
+	if (type == PGSTAT_EXTVAC_INDEX || type == PGSTAT_EXTVAC_TABLE)
+	{
+		Oid			relid = PG_GETARG_OID(1);
+		PgStat_VacuumRelationCounts *stats;
+
+		if (!OidIsValid(relid))
+			return (Datum) 0;
+
+		stats = (PgStat_VacuumRelationCounts *)
+			pgstat_fetch_entry(PGSTAT_KIND_EXTVAC_RELATION, dbid,
+							   EXTVAC_OBJID(relid, type), NULL);
+
+		if (!stats)
+			stats = (PgStat_VacuumRelationCounts *)
+				pgstat_fetch_entry(PGSTAT_KIND_EXTVAC_RELATION, InvalidOid,
+								   EXTVAC_OBJID(relid, type), NULL);
+
+		if (stats && stats->type == type)
+			tuplestore_put_for_relation(relid, tupstore, tupdesc, stats);
+	}
+	else if (type == PGSTAT_EXTVAC_DB)
+	{
+		if (OidIsValid(dbid))
+		{
+#define EXTVAC_DB_STAT_COLS 14
+			Datum		values[EXTVAC_DB_STAT_COLS];
+			bool		nulls[EXTVAC_DB_STAT_COLS];
+			int			i = 0;
+			PgStat_VacuumRelationCounts *stats;
+
+			stats = (PgStat_VacuumRelationCounts *)
+				pgstat_fetch_entry(PGSTAT_KIND_EXTVAC_DB, dbid,
+								   InvalidOid, NULL);
+			if (stats && stats->type == PGSTAT_EXTVAC_DB)
+			{
+				memset(nulls, 0, sizeof(nulls));
+				values[i++] = ObjectIdGetDatum(dbid);
+				tuplestore_put_common(&stats->common, values, nulls, &i);
+				values[i++] = Int32GetDatum(stats->common.interrupts_count);
+				Assert(i == EXTVAC_DB_STAT_COLS);
+				tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+			}
+		}
+		/* invalid dbid: return empty set */
+	}
+	else
+		elog(PANIC, "ext_vacuum_statistics: invalid type %d", type);
+
+	return (Datum) 0;
+}
+
+PG_FUNCTION_INFO_V1(pg_stats_get_vacuum_tables);
+PG_FUNCTION_INFO_V1(pg_stats_get_vacuum_indexes);
+PG_FUNCTION_INFO_V1(pg_stats_get_vacuum_database);
+
+Datum
+pg_stats_get_vacuum_tables(PG_FUNCTION_ARGS)
+{
+	return pg_stats_vacuum(fcinfo, PGSTAT_EXTVAC_TABLE);
+}
+
+Datum
+pg_stats_get_vacuum_indexes(PG_FUNCTION_ARGS)
+{
+	return pg_stats_vacuum(fcinfo, PGSTAT_EXTVAC_INDEX);
+}
+
+Datum
+pg_stats_get_vacuum_database(PG_FUNCTION_ARGS)
+{
+	return pg_stats_vacuum(fcinfo, PGSTAT_EXTVAC_DB);
+}
diff --git a/contrib/meson.build b/contrib/meson.build
index ebb7f83d8c5..d7dc0fd07f0 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -26,6 +26,7 @@ subdir('cube')
 subdir('dblink')
 subdir('dict_int')
 subdir('dict_xsyn')
+subdir('ext_vacuum_statistics')
 subdir('earthdistance')
 subdir('file_fdw')
 subdir('fuzzystrmatch')
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index b9b03654aad..2a38f9042bb 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -141,6 +141,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &dict-int;
  &dict-xsyn;
  &earthdistance;
+ &extvacuumstatistics;
  &file-fdw;
  &fuzzystrmatch;
  &hstore;
diff --git a/doc/src/sgml/extvacuumstatistics.sgml b/doc/src/sgml/extvacuumstatistics.sgml
new file mode 100644
index 00000000000..75eb4691c4d
--- /dev/null
+++ b/doc/src/sgml/extvacuumstatistics.sgml
@@ -0,0 +1,502 @@
+<!-- doc/src/sgml/extvacuumstatistics.sgml -->
+
+<sect1 id="extvacuumstatistics" xreflabel="ext_vacuum_statistics">
+ <title>ext_vacuum_statistics &mdash; extended vacuum statistics</title>
+
+ <indexterm zone="extvacuumstatistics">
+  <primary>ext_vacuum_statistics</primary>
+ </indexterm>
+
+ <para>
+  The <filename>ext_vacuum_statistics</filename> module provides
+  extended per-table, per-index, and per-database vacuum statistics
+  (buffer I/O, WAL, general, timing) via views in the
+  <literal>ext_vacuum_statistics</literal> schema.
+ </para>
+
+ <para>
+  The module must be loaded by adding <literal>ext_vacuum_statistics</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> in
+  <filename>postgresql.conf</filename>, because it registers a vacuum hook at
+  server startup.  This means that a server restart is needed to add or remove
+  the module.  After installation, run
+  <command>CREATE EXTENSION ext_vacuum_statistics</command> in each database
+  where you want to use it.
+ </para>
+
+ <para>
+  When active, the module provides views
+  <structname>ext_vacuum_statistics.pg_stats_vacuum_tables</structname>,
+  <structname>ext_vacuum_statistics.pg_stats_vacuum_indexes</structname>, and
+  <structname>ext_vacuum_statistics.pg_stats_vacuum_database</structname>,
+  plus functions to reset statistics and manage tracking.
+ </para>
+
+ <para>
+  Each tracked object (one table, one index, or one database) uses
+  approximately 232 bytes of shared memory on Linux x86_64 (e.g. Ubuntu):
+  common stats (buffers, WAL, timing) plus header and LWLock ~144 bytes;
+  type + union ~88 bytes (the union holds table-specific or index-specific
+  fields; the allocated size is the same for both).  The exact size depends on the platform.  Call
+  <function>ext_vacuum_statistics.shared_memory_size()</function> to get
+  the total shared memory used by the extension.  The extension's GUCs allow controlling memory by limiting
+  which objects are tracked:
+  <varname>vacuum_statistics.object_types</varname>,
+  <varname>vacuum_statistics.track_relations</varname>, and
+  <varname>track_*_from_list</varname>.
+  Example: a database with 1000 tables and 2000 indexes uses about 700 KB
+  on Ubuntu ((1000 + 2000 + 1) × 232 bytes).
+ </para>
+
+ <sect2 id="extvacuumstatistics-pg-stats-vacuum-tables">
+  <title>The <structname>ext_vacuum_statistics.pg_stats_vacuum_tables</structname> View</title>
+
+  <indexterm zone="extvacuumstatistics">
+   <secondary>pg_stats_vacuum_tables</secondary>
+  </indexterm>
+
+  <para>
+   The view <structname>ext_vacuum_statistics.pg_stats_vacuum_tables</structname>
+   contains one row for each table in the current database (including TOAST
+   tables), showing statistics about vacuuming that specific table.  The columns
+   are shown in <xref linkend="extvacuumstatistics-pg-stats-vacuum-tables-columns"/>.
+  </para>
+
+  <table id="extvacuumstatistics-pg-stats-vacuum-tables-columns">
+   <title><structname>ext_vacuum_statistics.pg_stats_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>relid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of a table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>schema</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the schema this 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 this table
+      </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 containing this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_read</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of database blocks read by vacuum operations performed on this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_hit</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of times database blocks were found in the buffer cache by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_dirtied</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of database blocks dirtied by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_written</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of database blocks written by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_records</structfield> <type>int8</type>
+      </para>
+      <para>
+       Total number of WAL records generated by vacuum operations performed on this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_fpi</structfield> <type>int8</type>
+      </para>
+      <para>
+       Total number of WAL full page images generated by vacuum operations
+      </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 bytes generated by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_read_time</structfield> <type>float8</type>
+      </para>
+      <para>
+       Time spent reading blocks by vacuum operations, in milliseconds
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_write_time</structfield> <type>float8</type>
+      </para>
+      <para>
+       Time spent writing blocks by vacuum operations, in milliseconds
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>delay_time</structfield> <type>float8</type>
+      </para>
+      <para>
+       Time spent in vacuum delay points, in milliseconds
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_time</structfield> <type>float8</type>
+      </para>
+      <para>
+       Total time of vacuuming this table, in milliseconds
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wraparound_failsafe_count</structfield> <type>int4</type>
+      </para>
+      <para>
+       Number of times vacuum was run to prevent a wraparound problem
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_read</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of blocks vacuum operations read from this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_hit</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of times blocks of this table were found in the buffer cache by vacuum
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_deleted</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of dead tuples vacuum operations deleted from this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_scanned</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages examined by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_removed</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages removed from physical storage by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_frozen_pages</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages newly set all-frozen by vacuum in the visibility map
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_visible_pages</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages newly set all-visible by vacuum in the visibility map
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_visible_frozen_pages</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages newly set all-visible and all-frozen by vacuum in the visibility map
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_frozen</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of tuples that vacuum operations marked as frozen
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>recently_dead_tuples</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of dead tuples left due to visibility in transactions
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>index_vacuum_count</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of times indexes on this table were vacuumed
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>missed_dead_pages</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages that had at least one missed dead tuple
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>missed_dead_tuples</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of fully DEAD tuples that could not be pruned due to failure to acquire a cleanup lock
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect2>
+
+ <sect2 id="extvacuumstatistics-pg-stats-vacuum-indexes">
+  <title>The <structname>ext_vacuum_statistics.pg_stats_vacuum_indexes</structname> View</title>
+
+  <indexterm zone="extvacuumstatistics">
+   <secondary>pg_stats_vacuum_indexes</secondary>
+  </indexterm>
+
+  <para>
+   The view <structname>ext_vacuum_statistics.pg_stats_vacuum_indexes</structname>
+   contains one row for each index in the current database, showing statistics
+   about vacuuming that specific index.  Columns include
+   <structfield>indexrelid</structfield>, <structfield>schema</structfield>,
+   <structfield>indexrelname</structfield>, <structfield>dbname</structfield>,
+   buffer I/O (<structfield>total_blks_read</structfield>,
+   <structfield>total_blks_hit</structfield>, etc.), WAL
+   (<structfield>wal_records</structfield>, <structfield>wal_fpi</structfield>,
+   <structfield>wal_bytes</structfield>), timing
+   (<structfield>blk_read_time</structfield>, <structfield>blk_write_time</structfield>,
+   <structfield>delay_time</structfield>, <structfield>total_time</structfield>),
+   and <structfield>tuples_deleted</structfield>, <structfield>pages_deleted</structfield>.
+  </para>
+ </sect2>
+
+ <sect2 id="extvacuumstatistics-pg-stats-vacuum-database">
+  <title>The <structname>ext_vacuum_statistics.pg_stats_vacuum_database</structname> View</title>
+
+  <indexterm zone="extvacuumstatistics">
+   <secondary>pg_stats_vacuum_database</secondary>
+  </indexterm>
+
+  <para>
+   The view <structname>ext_vacuum_statistics.pg_stats_vacuum_database</structname>
+   contains one row for each database in the cluster, showing aggregate vacuum
+   statistics for that database.  Columns include
+   <structfield>dboid</structfield>, <structfield>dbname</structfield>,
+   <structfield>db_blks_read</structfield>, <structfield>db_blks_hit</structfield>,
+   <structfield>db_blks_dirtied</structfield>, <structfield>db_blks_written</structfield>,
+   WAL stats (<structfield>db_wal_records</structfield>,
+   <structfield>db_wal_fpi</structfield>, <structfield>db_wal_bytes</structfield>),
+   timing (<structfield>db_blk_read_time</structfield>,
+   <structfield>db_blk_write_time</structfield>, <structfield>db_delay_time</structfield>,
+   <structfield>db_total_time</structfield>),
+   <structfield>db_wraparound_failsafe_count</structfield>, and
+   <structfield>interrupts_count</structfield>.
+  </para>
+ </sect2>
+
+ <sect2 id="extvacuumstatistics-functions">
+  <title>Functions</title>
+
+  <variablelist>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.shared_memory_size()</function>
+     <returnvalue>bigint</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Returns the total shared memory in bytes used by the extension for
+      vacuum statistics (relations plus databases).
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.vacuum_statistics_reset()</function>
+     <returnvalue>bigint</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Resets all vacuum statistics.  Returns the number of entries reset.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.add_track_database(dboid oid)</function>
+     <returnvalue>boolean</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Adds a database OID to the tracking list (persisted to
+      <filename>pg_stat/ext_vacuum_statistics_track.oid</filename>).
+      Returns true if newly added.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.remove_track_database(dboid oid)</function>
+     <returnvalue>boolean</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Removes a database OID from the tracking list.  Returns true if found and removed.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.add_track_relation(dboid oid, reloid oid)</function>
+     <returnvalue>boolean</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Adds a (database, relation) OID pair to the tracking list.  Returns true if newly added.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.remove_track_relation(dboid oid, reloid oid)</function>
+     <returnvalue>boolean</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Removes a (database, relation) pair from the tracking list.  Returns true if found and removed.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.track_list()</function>
+     <returnvalue>TABLE(track_kind text, dboid oid, reloid oid)</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Returns the list of database and relation OIDs for which vacuum statistics
+      are collected.  When <structfield>dboid</structfield> or
+      <structfield>reloid</structfield> is NULL, statistics are collected for all.
+     </para>
+    </listitem>
+   </varlistentry>
+  </variablelist>
+ </sect2>
+
+ <sect2 id="extvacuumstatistics-configuration">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+   <varlistentry>
+    <term><varname>vacuum_statistics.enabled</varname> (<type>boolean</type>)</term>
+    <listitem>
+     <para>
+      Enables extended vacuum statistics collection.  Default: <literal>on</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term><varname>vacuum_statistics.object_types</varname> (<type>string</type>)</term>
+    <listitem>
+     <para>
+      Object types for statistics: <literal>all</literal>, <literal>databases</literal>, or
+      <literal>relations</literal>.  Default: <literal>all</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term><varname>vacuum_statistics.track_relations</varname> (<type>string</type>)</term>
+    <listitem>
+     <para>
+      When tracking relations: <literal>all</literal>, <literal>system</literal>, or
+      <literal>user</literal>.  Default: <literal>all</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term><varname>vacuum_statistics.track_databases_from_list</varname> (<type>boolean</type>)</term>
+    <listitem>
+     <para>
+      If on, track only databases added via <function>add_track_database</function>.
+      Default: <literal>off</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term><varname>vacuum_statistics.track_relations_from_list</varname> (<type>boolean</type>)</term>
+    <listitem>
+     <para>
+      If on, track only relations added via <function>add_track_relation</function>.
+      Default: <literal>off</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+  </variablelist>
+ </sect2>
+</sect1>
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 25a85082759..85d721467c0 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -133,6 +133,7 @@
 <!ENTITY dict-xsyn       SYSTEM "dict-xsyn.sgml">
 <!ENTITY dummy-seclabel  SYSTEM "dummy-seclabel.sgml">
 <!ENTITY earthdistance   SYSTEM "earthdistance.sgml">
+<!ENTITY extvacuumstatistics SYSTEM "extvacuumstatistics.sgml">
 <!ENTITY file-fdw        SYSTEM "file-fdw.sgml">
 <!ENTITY fuzzystrmatch   SYSTEM "fuzzystrmatch.sgml">
 <!ENTITY hstore          SYSTEM "hstore.sgml">
-- 
2.39.5 (Apple Git-154)



Attachments:

  [text/plain] v39-0001-Track-table-VM-stability.patch (21.7K, 3-v39-0001-Track-table-VM-stability.patch)
  download | inline diff:
From 19f5a39f7e97d3fc2d18415ba2c51ffcd3b32f49 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Mon, 30 Mar 2026 09:07:24 +0300
Subject: [PATCH 1/3] 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         |   1 +
 .../specs/vacuum-extending-freeze.spec        | 117 +++++++++++
 src/test/regress/expected/rules.out           |  12 +-
 11 files changed, 391 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 08d5b824552..3467abf6d8a 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 4fd470702aa..f055ec3819c 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 73a1c1c4670..71e993c8783 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 b2ca28f83ba..92e1f60a080 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 1408de387ea..b6f064338fe 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 fa9ae79082b..f8241268017 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12769,4 +12769,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 dfa2e837638..7db36cf8add 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 00000000000..994a8df56df
--- /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 1578ba191c8..91ffc57ebd4 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -126,3 +126,4 @@ test: serializable-parallel-3
 test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
+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 00000000000..17c204e2326
--- /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 a65a5bf0c4f..096e4f763f3 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] v39-0002-Machinery-for-grabbing-extended-vacuum-statistics.patch (25.0K, 4-v39-0002-Machinery-for-grabbing-extended-vacuum-statistics.patch)
  download | inline diff:
From 3a5e0bd82578d1fea63d6bda229dc4d0b224684e Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Mon, 2 Mar 2026 23:09:32 +0300
Subject: [PATCH 2/3] Machinery for grabbing extended vacuum statistics.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add infrastructure inside lazy vacuum to gather extended per-vacuum
metrics and expose them to extensions via a new hook. Core itself
does not persist these metrics — that is the job of an extension
(see ext_vacuum_statistics).

Statistics are gathered separately for tables and indexes according
to vacuum phases. The ExtVacReport union and type field distinguish
PGSTAT_EXTVAC_TABLE vs PGSTAT_EXTVAC_INDEX. Heap vacuum stats are
sent to the cumulative statistics system after vacuum has processed
the indexes. Database vacuum statistics aggregate per-table and
per-index statistics within the database.

Common for tables, indexes, and database: total_blks_hit, total_blks_read
and total_blks_dirtied are the number of hit, miss and dirtied pages
in shared buffers during a vacuum operation. total_blks_dirtied counts
only pages dirtied by this vacuum. blk_read_time and blk_write_time
track access and flush time for buffer pages; blk_write_time can stay
zero if no flushes occurred. total_time is wall-clock time from start
to finish, including idle time (I/O and lock waits). delay_time is
total vacuum sleep time in vacuum delay points.

Both table and index report tuples_deleted (tuples removed by the vacuum),
pages_removed (pages by which relation storage was reduced) and
pages_deleted (freed pages; file size may remain unchanged). These are
independent of WAL and buffer stats and are not summed at the database
level.

Table only: pages_frozen (pages marked all-frozen in the visibility map),
pages_all_visible (pages marked all-visible in the visibility map),
wraparound_failsafe_count (number of urgent anti-wraparound vacuums).

Table and database share wraparound_failsafe (count of urgent anti-wraparound
cleanups). Database only: errors (number of error-level errors caught
during vacuum).

set_report_vacuum_hook (set_report_vacuum_hook_type) -- called
once per vacuumed relation/index with a PgStat_VacuumRelationCounts
payload tagged by ExtVacReportType (PGSTAT_EXTVAC_TABLE / _INDEX /
_DB / _INVALID).

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]>
---
 src/backend/access/heap/vacuumlazy.c         | 234 ++++++++++++++++++-
 src/backend/commands/vacuum.c                |   4 +
 src/backend/commands/vacuumparallel.c        |  12 +
 src/backend/utils/activity/pgstat_relation.c |  24 ++
 src/include/commands/vacuum.h                |  29 +++
 src/include/pgstat.h                         |  69 ++++++
 6 files changed, 367 insertions(+), 5 deletions(-)

diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index 39395aed0d5..e4d4c93d641 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -283,6 +283,8 @@ typedef struct LVRelState
 	/* Error reporting state */
 	char	   *dbname;
 	char	   *relnamespace;
+	Oid			reloid;
+	Oid			indoid;
 	char	   *relname;
 	char	   *indname;		/* Current index name */
 	BlockNumber blkno;			/* used only for heap operations */
@@ -410,6 +412,15 @@ typedef struct LVRelState
 	 * been permanently disabled.
 	 */
 	BlockNumber eager_scan_remaining_fails;
+
+	int32		wraparound_failsafe_count;	/* # of emergency vacuums for
+											 * anti-wraparound */
+
+	/*
+	 * We need to accumulate index statistics for later subtraction from heap
+	 * stats.
+	 */
+	PgStat_VacuumRelationCounts extVacReportIdx;
 } LVRelState;
 
 
@@ -485,6 +496,166 @@ static void restore_vacuum_error_info(LVRelState *vacrel,
 									  const LVSavedErrInfo *saved_vacrel);
 
 
+/* Extended vacuum statistics functions */
+
+/*
+ * extvac_stats_start - Save cut-off values before start of relation processing.
+ */
+static void
+extvac_stats_start(Relation rel, LVExtStatCounters * counters)
+{
+	memset(counters, 0, sizeof(LVExtStatCounters));
+	counters->starttime = GetCurrentTimestamp();
+	counters->walusage = pgWalUsage;
+	counters->bufusage = pgBufferUsage;
+	counters->VacuumDelayTime = VacuumDelayTime;
+	counters->blocks_fetched = 0;
+	counters->blocks_hit = 0;
+
+	if (rel->pgstat_info && pgstat_track_counts)
+	{
+		counters->blocks_fetched = rel->pgstat_info->counts.blocks_fetched;
+		counters->blocks_hit = rel->pgstat_info->counts.blocks_hit;
+	}
+}
+
+/*
+ * extvac_stats_end - Finish extended vacuum statistic gathering and form report.
+ */
+static void
+extvac_stats_end(Relation rel, LVExtStatCounters * counters,
+				 PgStat_CommonCounts * report)
+{
+	WalUsage	walusage;
+	BufferUsage bufusage;
+	TimestampTz endtime;
+	long		secs;
+	int			usecs;
+
+	memset(report, 0, sizeof(PgStat_CommonCounts));
+	memset(&walusage, 0, sizeof(WalUsage));
+	WalUsageAccumDiff(&walusage, &pgWalUsage, &counters->walusage);
+	memset(&bufusage, 0, sizeof(BufferUsage));
+	BufferUsageAccumDiff(&bufusage, &pgBufferUsage, &counters->bufusage);
+	endtime = GetCurrentTimestamp();
+	TimestampDifference(counters->starttime, endtime, &secs, &usecs);
+
+	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;
+	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) +
+		INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_read_time);
+	report->blk_write_time = INSTR_TIME_GET_MILLISEC(bufusage.local_blk_write_time) +
+		INSTR_TIME_GET_MILLISEC(bufusage.shared_blk_write_time);
+	report->delay_time = VacuumDelayTime - counters->VacuumDelayTime;
+	report->total_time = secs * 1000.0 + usecs / 1000.0;
+
+	if (rel->pgstat_info && pgstat_track_counts)
+	{
+		report->blks_fetched = rel->pgstat_info->counts.blocks_fetched - counters->blocks_fetched;
+		report->blks_hit = rel->pgstat_info->counts.blocks_hit - counters->blocks_hit;
+	}
+}
+
+/*
+ * extvac_stats_start_idx - Start extended vacuum statistic gathering for index.
+ */
+void
+extvac_stats_start_idx(Relation rel, IndexBulkDeleteResult *stats,
+					   LVExtStatCountersIdx * counters)
+{
+	extvac_stats_start(rel, &counters->common);
+	counters->pages_deleted = 0;
+	counters->tuples_removed = 0;
+
+	if (stats != NULL)
+	{
+		counters->tuples_removed = stats->tuples_removed;
+		counters->pages_deleted = stats->pages_deleted;
+	}
+}
+
+
+/*
+ * extvac_stats_end_idx - Finish extended vacuum statistic gathering for index.
+ */
+void
+extvac_stats_end_idx(Relation rel, IndexBulkDeleteResult *stats,
+					 LVExtStatCountersIdx * counters, PgStat_VacuumRelationCounts * report)
+{
+	memset(report, 0, sizeof(PgStat_VacuumRelationCounts));
+	extvac_stats_end(rel, &counters->common, &report->common);
+	report->type = PGSTAT_EXTVAC_INDEX;
+
+	if (stats != NULL)
+	{
+		report->common.tuples_deleted = stats->tuples_removed - counters->tuples_removed;
+		report->index.pages_deleted = stats->pages_deleted - counters->pages_deleted;
+	}
+}
+
+/*
+ * Accumulate index stats into vacrel for later subtraction from heap stats.
+ * It needs to prevent double-counting of stats for heaps that
+ * include indexes because indexes are vacuumed before the heap.
+ * We need to be careful with buffer usage and wal usage during parallel vacuum
+ * because they are accumulated summarly for all indexes at once by leader after
+ * all workers have finished.
+ */
+static void
+accumulate_idxs_vacuum_statistics(LVRelState *vacrel,
+								  PgStat_VacuumRelationCounts * extVacIdxStats)
+{
+	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.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;
+}
+
+/* Build heap-specific extended stats */
+static void
+accumulate_heap_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCounts * extVacStats)
+{
+	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->table.index_vacuum_count = vacrel->num_index_scans;
+	extVacStats->common.wraparound_failsafe_count = vacrel->wraparound_failsafe_count;
+
+	/* Hook is invoked from pgstat_report_vacuum() when extstats is passed */
+
+	/* Subtract index stats from heap to avoid double-counting */
+	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.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;
+}
 
 /*
  * Helper to set up the eager scanning state for vacuuming a single relation.
@@ -643,7 +814,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;
 
+	memset(&extVacReport, 0, sizeof(extVacReport));
 	verbose = (params->options & VACOPT_VERBOSE) != 0;
 	instrument = (verbose || (AmAutoVacuumWorkerProcess() &&
 							  params->log_vacuum_min_duration >= 0));
@@ -660,6 +834,9 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	/* Used for instrumentation and stats report */
 	starttime = GetCurrentTimestamp();
 
+	if (set_report_vacuum_hook)
+		extvac_stats_start(rel, &extVacCounters);
+
 	pgstat_progress_start_command(PROGRESS_COMMAND_VACUUM,
 								  RelationGetRelid(rel));
 	if (AmAutoVacuumWorkerProcess())
@@ -687,7 +864,9 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel->dbname = get_database_name(MyDatabaseId);
 	vacrel->relnamespace = get_namespace_name(RelationGetNamespace(rel));
 	vacrel->relname = pstrdup(RelationGetRelationName(rel));
+	vacrel->reloid = RelationGetRelid(rel);
 	vacrel->indname = NULL;
+	memset(&vacrel->extVacReportIdx, 0, sizeof(vacrel->extVacReportIdx));
 	vacrel->phase = VACUUM_ERRCB_PHASE_UNKNOWN;
 	vacrel->verbose = verbose;
 	errcallback.callback = vacuum_error_callback;
@@ -803,6 +982,9 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel->rel_pages = orig_rel_pages = RelationGetNumberOfBlocks(rel);
 	vacrel->vistest = GlobalVisTestFor(rel);
 
+	/* Initialize wraparound failsafe count for extended vacuum stats */
+	vacrel->wraparound_failsafe_count = 0;
+
 	/* Initialize state used to track oldest extant XID/MXID */
 	vacrel->NewRelfrozenXid = vacrel->cutoffs.OldestXmin;
 	vacrel->NewRelminMxid = vacrel->cutoffs.OldestMxact;
@@ -985,11 +1167,26 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	 * soon in cases where the failsafe prevented significant amounts of heap
 	 * vacuuming.
 	 */
-	pgstat_report_vacuum(rel,
-						 Max(vacrel->new_live_tuples, 0),
-						 vacrel->recently_dead_tuples +
-						 vacrel->missed_dead_tuples,
-						 starttime);
+	if (set_report_vacuum_hook)
+	{
+		extvac_stats_end(rel, &extVacCounters, &extVacReport.common);
+		accumulate_heap_vacuum_statistics(vacrel, &extVacReport);
+
+		pgstat_report_vacuum_ext(rel,
+								 Max(vacrel->new_live_tuples, 0),
+								 vacrel->recently_dead_tuples +
+								 vacrel->missed_dead_tuples,
+								 starttime,
+								 &extVacReport);
+	}
+	else
+		pgstat_report_vacuum_ext(rel,
+								 Max(vacrel->new_live_tuples, 0),
+								 vacrel->recently_dead_tuples +
+								 vacrel->missed_dead_tuples,
+								 starttime,
+								 NULL);
+
 	pgstat_progress_end_command();
 
 	if (instrument)
@@ -2903,6 +3100,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
@@ -3015,7 +3213,11 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 {
 	IndexVacuumInfo ivinfo;
 	LVSavedErrInfo saved_err_info;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
 
+	if (set_report_vacuum_hook)
+		extvac_stats_start_idx(indrel, istat, &extVacCounters);
 	ivinfo.index = indrel;
 	ivinfo.heaprel = vacrel->rel;
 	ivinfo.analyze_only = false;
@@ -3033,6 +3235,7 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 	 */
 	Assert(vacrel->indname == NULL);
 	vacrel->indname = pstrdup(RelationGetRelationName(indrel));
+	vacrel->indoid = RelationGetRelid(indrel);
 	update_vacuum_error_info(vacrel, &saved_err_info,
 							 VACUUM_ERRCB_PHASE_VACUUM_INDEX,
 							 InvalidBlockNumber, InvalidOffsetNumber);
@@ -3041,6 +3244,14 @@ lazy_vacuum_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 	istat = vac_bulkdel_one_index(&ivinfo, istat, vacrel->dead_items,
 								  vacrel->dead_items_info);
 
+	if (set_report_vacuum_hook)
+	{
+		memset(&extVacReport, 0, sizeof(extVacReport));
+		extvac_stats_end_idx(indrel, istat, &extVacCounters, &extVacReport);
+		pgstat_report_vacuum_ext(indrel, -1, -1, 0, &extVacReport);
+		accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+	}
+
 	/* Revert to the previous phase information for error traceback */
 	restore_vacuum_error_info(vacrel, &saved_err_info);
 	pfree(vacrel->indname);
@@ -3065,7 +3276,11 @@ lazy_cleanup_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 {
 	IndexVacuumInfo ivinfo;
 	LVSavedErrInfo saved_err_info;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
 
+	if (set_report_vacuum_hook)
+		extvac_stats_start_idx(indrel, istat, &extVacCounters);
 	ivinfo.index = indrel;
 	ivinfo.heaprel = vacrel->rel;
 	ivinfo.analyze_only = false;
@@ -3084,12 +3299,21 @@ lazy_cleanup_one_index(Relation indrel, IndexBulkDeleteResult *istat,
 	 */
 	Assert(vacrel->indname == NULL);
 	vacrel->indname = pstrdup(RelationGetRelationName(indrel));
+	vacrel->indoid = RelationGetRelid(indrel);
 	update_vacuum_error_info(vacrel, &saved_err_info,
 							 VACUUM_ERRCB_PHASE_INDEX_CLEANUP,
 							 InvalidBlockNumber, InvalidOffsetNumber);
 
 	istat = vac_cleanup_one_index(&ivinfo, istat);
 
+	if (set_report_vacuum_hook)
+	{
+		memset(&extVacReport, 0, sizeof(extVacReport));
+		extvac_stats_end_idx(indrel, istat, &extVacCounters, &extVacReport);
+		pgstat_report_vacuum_ext(indrel, -1, -1, 0, &extVacReport);
+		accumulate_idxs_vacuum_statistics(vacrel, &extVacReport);
+	}
+
 	/* 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/commands/vacuum.c b/src/backend/commands/vacuum.c
index 99d0db82ed7..a7fb73173f5 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 (msec). */
+double		VacuumDelayTime = 0;
+
 /* non-export function prototypes */
 static List *expand_vacuum_rel(VacuumRelation *vrel,
 							   MemoryContext vac_context, int options);
@@ -2561,6 +2564,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 41cefcfde54..200f12a2d1b 100644
--- a/src/backend/commands/vacuumparallel.c
+++ b/src/backend/commands/vacuumparallel.c
@@ -1076,6 +1076,8 @@ parallel_vacuum_process_one_index(ParallelVacuumState *pvs, Relation indrel,
 	IndexBulkDeleteResult *istat = NULL;
 	IndexBulkDeleteResult *istat_res;
 	IndexVacuumInfo ivinfo;
+	LVExtStatCountersIdx extVacCounters;
+	PgStat_VacuumRelationCounts extVacReport;
 
 	/*
 	 * Update the pointer to the corresponding bulk-deletion result if someone
@@ -1084,6 +1086,8 @@ parallel_vacuum_process_one_index(ParallelVacuumState *pvs, Relation indrel,
 	if (indstats->istat_updated)
 		istat = &(indstats->istat);
 
+	if (set_report_vacuum_hook)
+		extvac_stats_start_idx(indrel, istat, &extVacCounters);
 	ivinfo.index = indrel;
 	ivinfo.heaprel = pvs->heaprel;
 	ivinfo.analyze_only = false;
@@ -1112,6 +1116,13 @@ parallel_vacuum_process_one_index(ParallelVacuumState *pvs, Relation indrel,
 				 RelationGetRelationName(indrel));
 	}
 
+	if (set_report_vacuum_hook)
+	{
+		memset(&extVacReport, 0, sizeof(extVacReport));
+		extvac_stats_end_idx(indrel, istat_res, &extVacCounters, &extVacReport);
+		pgstat_report_vacuum_ext(indrel, -1, -1, 0, &extVacReport);
+	}
+
 	/*
 	 * Copy the index bulk-deletion result returned from ambulkdelete and
 	 * amvacuumcleanup to the DSM segment if it's the first cycle because they
@@ -1276,6 +1287,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_relation.c b/src/backend/utils/activity/pgstat_relation.c
index 92e1f60a080..226d7aa06d5 100644
--- a/src/backend/utils/activity/pgstat_relation.c
+++ b/src/backend/utils/activity/pgstat_relation.c
@@ -272,6 +272,30 @@ pgstat_report_vacuum(Relation rel, PgStat_Counter livetuples,
 	(void) pgstat_flush_backend(false, PGSTAT_BACKEND_FLUSH_IO);
 }
 
+/*
+ * Hook for extensions to receive extended vacuum statistics.
+ * NULL when no extension has registered.
+ */
+set_report_vacuum_hook_type set_report_vacuum_hook = NULL;
+
+/*
+ * Report extended vacuum statistics to extensions via set_report_vacuum_hook.
+ * When livetuples/deadtuples/starttime are provided (heap case), also calls
+ * pgstat_report_vacuum. For indexes, pass -1, -1, 0 to skip pgstat_report_vacuum.
+ */
+void
+pgstat_report_vacuum_ext(Relation rel, PgStat_Counter livetuples,
+						 PgStat_Counter deadtuples, TimestampTz starttime,
+						 PgStat_VacuumRelationCounts * extstats)
+{
+	pgstat_report_vacuum(rel, livetuples, deadtuples, starttime);
+
+	if (extstats != NULL && set_report_vacuum_hook)
+		(*set_report_vacuum_hook) (RelationGetRelid(rel),
+								   rel->rd_rel->relisshared,
+								   extstats);
+}
+
 /*
  * Report that the table was just analyzed and flush IO statistics.
  *
diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h
index 956d9cea36d..a925f7da992 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
@@ -354,6 +356,33 @@ extern PGDLLIMPORT pg_atomic_uint32 *VacuumSharedCostBalance;
 extern PGDLLIMPORT pg_atomic_uint32 *VacuumActiveNWorkers;
 extern PGDLLIMPORT int VacuumCostBalanceLocal;
 
+/* Cumulative storage to report total vacuum delay time (msec). */
+extern PGDLLIMPORT double VacuumDelayTime;
+
+/* Counters for extended vacuum statistics gathering */
+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;
+
+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 PGDLLIMPORT bool VacuumFailsafeActive;
 extern PGDLLIMPORT double vacuum_cost_delay;
 extern PGDLLIMPORT int vacuum_cost_limit;
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index 7db36cf8add..8d934973dc1 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -93,6 +93,64 @@ typedef struct PgStat_FunctionCounts
 /*
  * Working state needed to accumulate per-function-call timing statistics.
  */
+/*
+ * Extended vacuum statistics - passed to extensions via set_report_vacuum_hook.
+ * Type of entry: table (heap), index, or database aggregate.
+ */
+typedef enum ExtVacReportType
+{
+	PGSTAT_EXTVAC_INVALID = 0,
+	PGSTAT_EXTVAC_TABLE = 1,
+	PGSTAT_EXTVAC_INDEX = 2,
+	PGSTAT_EXTVAC_DB = 3,
+}			ExtVacReportType;
+
+typedef struct PgStat_CommonCounts
+{
+	int64		total_blks_read;
+	int64		total_blks_hit;
+	int64		total_blks_dirtied;
+	int64		total_blks_written;
+	int64		blks_fetched;
+	int64		blks_hit;
+	int64		wal_records;
+	int64		wal_fpi;
+	uint64		wal_bytes;
+	double		blk_read_time;
+	double		blk_write_time;
+	double		delay_time;
+	double		total_time;
+	int32		wraparound_failsafe_count;
+	int32		interrupts_count;
+	int64		tuples_deleted;
+}			PgStat_CommonCounts;
+
+typedef struct PgStat_VacuumRelationCounts
+{
+	PgStat_CommonCounts common;
+	ExtVacReportType type;
+	union
+	{
+		struct
+		{
+			int64		tuples_frozen;
+			int64		recently_dead_tuples;
+			int64		missed_dead_tuples;
+			int64		pages_scanned;
+			int64		pages_removed;
+			int64		vm_new_frozen_pages;
+			int64		vm_new_visible_pages;
+			int64		vm_new_visible_frozen_pages;
+			int64		missed_dead_pages;
+			int64		index_vacuum_count;
+		}			table;
+		struct
+		{
+			int64		pages_deleted;
+		}			index;
+	};
+}			PgStat_VacuumRelationCounts;
+
 typedef struct PgStat_FunctionCallUsage
 {
 	/* Link to function's hashtable entry (must still be there at exit!) */
@@ -703,6 +761,17 @@ extern void pgstat_unlink_relation(Relation rel);
 extern void pgstat_report_vacuum(Relation rel, PgStat_Counter livetuples,
 								 PgStat_Counter deadtuples,
 								 TimestampTz starttime);
+
+extern void pgstat_report_vacuum_ext(Relation rel,
+									 PgStat_Counter livetuples,
+									 PgStat_Counter deadtuples,
+									 TimestampTz starttime,
+									 PgStat_VacuumRelationCounts * extstats);
+
+/* Hook for extensions to receive extended vacuum statistics */
+typedef void (*set_report_vacuum_hook_type) (Oid tableoid, bool shared,
+											 PgStat_VacuumRelationCounts * params);
+extern PGDLLIMPORT set_report_vacuum_hook_type set_report_vacuum_hook;
 extern void pgstat_report_analyze(Relation rel,
 								  PgStat_Counter livetuples, PgStat_Counter deadtuples,
 								  bool resetcounter, TimestampTz starttime);
-- 
2.39.5 (Apple Git-154)



  [text/plain] v39-0003-ext_vacuum_statistics-extension-for-extended-vacuum-.patch (145.2K, 5-v39-0003-ext_vacuum_statistics-extension-for-extended-vacuum-.patch)
  download | inline diff:
From 3011a3cfd9ee3d6e4d1c5a12e3d9984f6b6a194e Mon Sep 17 00:00:00 2001
From: Alena Rybakina <[email protected]>
Date: Tue, 28 Apr 2026 03:43:29 +0300
Subject: [PATCH 3/3] ext_vacuum_statistics: extension for extended vacuum
 statistics

Introduce a new extension that collects extended per-vacuum
metrics via set_report_vacuum_hook and stores them through pgstat's
custom statistics infrastructure.

Tracking scope is controlled by GUCs:

  * vacuum_statistics.enabled       -- master switch
  * vacuum_statistics.object_types  -- databases / relations / all
  * vacuum_statistics.track_relations -- system / user / all
  * vacuum_statistics.track_{databases,relations}_from_list
          -- restrict tracking to objects registered via
             add_track_database() / add_track_relation();
             removal via remove_track_*() and OAT_DROP hook
  * vacuum_statistics.collect       -- buffers / wal /
            general / timing / all, consulted by ACCUM_IF() to skip
            unwanted categories at run time

 add_track_* / remove_track_* require superuser or pg_read_all_stats.
---
 contrib/Makefile                              |    1 +
 contrib/ext_vacuum_statistics/Makefile        |   24 +
 contrib/ext_vacuum_statistics/README.md       |  165 ++
 .../expected/ext_vacuum_statistics.out        |   52 +
 .../vacuum-extending-in-repetable-read.out    |   52 +
 .../ext_vacuum_statistics--1.0.sql            |  272 ++++
 .../ext_vacuum_statistics.conf                |    2 +
 .../ext_vacuum_statistics.control             |    5 +
 contrib/ext_vacuum_statistics/meson.build     |   41 +
 .../vacuum-extending-in-repetable-read.spec   |   59 +
 .../t/052_vacuum_extending_basic_test.pl      |  780 +++++++++
 .../t/053_vacuum_extending_freeze_test.pl     |  285 ++++
 .../t/054_vacuum_extending_gucs_test.pl       |  279 ++++
 .../ext_vacuum_statistics/vacuum_statistics.c | 1387 +++++++++++++++++
 contrib/meson.build                           |    1 +
 doc/src/sgml/contrib.sgml                     |    1 +
 doc/src/sgml/extvacuumstatistics.sgml         |  502 ++++++
 doc/src/sgml/filelist.sgml                    |    1 +
 18 files changed, 3909 insertions(+)
 create mode 100644 contrib/ext_vacuum_statistics/Makefile
 create mode 100644 contrib/ext_vacuum_statistics/README.md
 create mode 100644 contrib/ext_vacuum_statistics/expected/ext_vacuum_statistics.out
 create mode 100644 contrib/ext_vacuum_statistics/expected/vacuum-extending-in-repetable-read.out
 create mode 100644 contrib/ext_vacuum_statistics/ext_vacuum_statistics--1.0.sql
 create mode 100644 contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf
 create mode 100644 contrib/ext_vacuum_statistics/ext_vacuum_statistics.control
 create mode 100644 contrib/ext_vacuum_statistics/meson.build
 create mode 100644 contrib/ext_vacuum_statistics/specs/vacuum-extending-in-repetable-read.spec
 create mode 100644 contrib/ext_vacuum_statistics/t/052_vacuum_extending_basic_test.pl
 create mode 100644 contrib/ext_vacuum_statistics/t/053_vacuum_extending_freeze_test.pl
 create mode 100644 contrib/ext_vacuum_statistics/t/054_vacuum_extending_gucs_test.pl
 create mode 100644 contrib/ext_vacuum_statistics/vacuum_statistics.c
 create mode 100644 doc/src/sgml/extvacuumstatistics.sgml

diff --git a/contrib/Makefile b/contrib/Makefile
index 7d91fe77db3..3140f2bf844 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -19,6 +19,7 @@ SUBDIRS = \
 		dict_int	\
 		dict_xsyn	\
 		earthdistance	\
+		ext_vacuum_statistics \
 		file_fdw	\
 		fuzzystrmatch	\
 		hstore		\
diff --git a/contrib/ext_vacuum_statistics/Makefile b/contrib/ext_vacuum_statistics/Makefile
new file mode 100644
index 00000000000..ed80bdf28d0
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/Makefile
@@ -0,0 +1,24 @@
+# contrib/ext_vacuum_statistics/Makefile
+
+EXTENSION = ext_vacuum_statistics
+MODULE_big = ext_vacuum_statistics
+OBJS = vacuum_statistics.o
+DATA = ext_vacuum_statistics--1.0.sql
+PGFILEDESC = "ext_vacuum_statistics - convenience views for extended vacuum statistics"
+
+ISOLATION = vacuum-extending-in-repetable-read
+ISOLATION_OPTS = --temp-config=$(top_srcdir)/contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf
+TAP_TESTS = 1
+
+NO_INSTALLCHECK = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = contrib/ext_vacuum_statistics
+top_builddir = ../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/contrib/ext_vacuum_statistics/README.md b/contrib/ext_vacuum_statistics/README.md
new file mode 100644
index 00000000000..51697eab023
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/README.md
@@ -0,0 +1,165 @@
+# ext_vacuum_statistics
+
+Extended vacuum statistics extension for PostgreSQL. It collects and exposes detailed per-table, per-index, and per-database vacuum statistics (buffer I/O, WAL, general, timing) via convenient views in the `ext_vacuum_statistics` schema.
+
+## Installation
+
+```
+./configure tmp_install="$(pwd)/my/inst"
+make clean && make && make install
+cd contrib/ext_vacuum_statistics
+make && make install
+```
+
+It is essential that the extension is listed in `shared_preload_libraries` because it registers a vacuum hook at server startup.
+
+In your `postgresql.conf`:
+
+```
+shared_preload_libraries = 'ext_vacuum_statistics'
+```
+
+Restart PostgreSQL.
+
+In your database:
+
+```sql
+CREATE EXTENSION ext_vacuum_statistics;
+```
+
+## Usage
+
+Query vacuum statistics via the provided views:
+
+```sql
+-- Per-table heap vacuum statistics
+SELECT * FROM ext_vacuum_statistics.pg_stats_vacuum_tables;
+
+-- Per-index vacuum statistics
+SELECT * FROM ext_vacuum_statistics.pg_stats_vacuum_indexes;
+
+-- Per-database aggregate vacuum statistics
+SELECT * FROM ext_vacuum_statistics.pg_stats_vacuum_database;
+```
+
+Example output:
+
+```
+ relname   | total_blks_read | total_blks_hit | wal_records | tuples_deleted | pages_removed
+-----------+-----------------+----------------+-------------+----------------+---------------
+ mytable   |             120 |            340 |          15 |            500 |            10
+```
+
+Reset statistics when needed:
+
+```sql
+SELECT ext_vacuum_statistics.vacuum_statistics_reset();
+```
+
+## Configuration (GUCs)
+
+| GUC | Default | Description |
+|-----|---------|-------------|
+| `vacuum_statistics.enabled` | on | Enable extended vacuum statistics collection |
+| `vacuum_statistics.object_types` | all | Object types for statistics: `all`, `databases`, `relations` |
+| `vacuum_statistics.track_relations` | all | When tracking relations: `all`, `system`, `user` |
+| `vacuum_statistics.track_databases_from_list` | off | If on, track only databases added via add_track_database |
+| `vacuum_statistics.track_relations_from_list` | off | If on, track only relations added via add_track_relation |
+
+## Memory usage
+
+Each tracked object (table, index, or database) uses approximately **232 bytes** of shared memory on Linux x86_64 (e.g. Ubuntu): common stats (buffers, WAL, timing) ~144 bytes; type + union ~88 bytes (union holds table-specific or index-specific fields, allocated size is the same for both).
+
+The exact size depends on the platform. Call `ext_vacuum_statistics.shared_memory_size()` to get the total shared memory used by the extension. The GUCs provided by the extension allow controlling the amount of memory used: `vacuum_statistics.object_types` to track only databases or relations, `vacuum_statistics.track_relations` to restrict to user or system tables/indexes, and `track_*_from_list` to track only selected databases and relations.
+
+Example: a database with 1000 tables and 2000 indexes, all tracked, uses about **700 KB** on Ubuntu (3001 entries × 232 bytes). Per-database entries add one entry per tracked database.
+
+## Advanced tuning
+
+### Track only database-level stats
+
+```sql
+SET vacuum_statistics.object_types = 'databases';
+```
+
+Statistics are accumulated per database; per-relation views remain empty.
+
+### Track only user or system tables
+
+```sql
+SET vacuum_statistics.object_types = 'relations';
+SET vacuum_statistics.track_relations = 'user';   -- skip system catalogs
+-- or
+SET vacuum_statistics.track_relations = 'system'; -- only system catalogs
+```
+
+### Filter by database or relation OIDs
+
+Add OIDs via functions (persisted to `pg_stat/ext_vacuum_statistics_track.oid`) and enable filtering:
+
+```sql
+-- Add databases and relations to track
+SELECT ext_vacuum_statistics.add_track_database(16384);
+SELECT ext_vacuum_statistics.add_track_relation(16384, 16385);  -- dboid, reloid
+SELECT ext_vacuum_statistics.add_track_relation(0, 16386);      -- rel 16386 in any db
+
+-- Enable list-based filtering (off = track all)
+SET vacuum_statistics.track_databases_from_list = on;
+SET vacuum_statistics.track_relations_from_list = on;
+```
+
+Remove OIDs when no longer needed:
+
+```sql
+SELECT ext_vacuum_statistics.remove_track_database(16384);
+SELECT ext_vacuum_statistics.remove_track_relation(16384, 16385);
+```
+
+Inspect the current tracking configuration:
+
+```sql
+SELECT * FROM ext_vacuum_statistics.track_list();
+```
+
+Returns `track_kind`, `dboid`, `reloid`. When `dboid` or `reloid` is NULL, statistics are collected for all.
+
+## Recipes
+
+**Reduce overhead by tracking only databases:**
+
+```sql
+SET vacuum_statistics.object_types = 'databases';
+```
+
+**Track only a specific table in a specific database:**
+
+```sql
+SELECT ext_vacuum_statistics.add_track_database(
+    (SELECT oid FROM pg_database WHERE datname = current_database())
+);
+SELECT ext_vacuum_statistics.add_track_relation(
+    (SELECT oid FROM pg_database WHERE datname = current_database()),
+    'mytable'::regclass
+);
+SET vacuum_statistics.track_databases_from_list = on;
+SET vacuum_statistics.track_relations_from_list = on;
+```
+
+**Disable statistics collection temporarily:**
+
+```sql
+SET vacuum_statistics.enabled = off;
+```
+
+## Views
+
+| View | Description |
+|------|-------------|
+| `ext_vacuum_statistics.pg_stats_vacuum_tables` | Per-table heap vacuum stats (pages scanned, tuples deleted, WAL, timing, etc.) |
+| `ext_vacuum_statistics.pg_stats_vacuum_indexes` | Per-index vacuum stats |
+| `ext_vacuum_statistics.pg_stats_vacuum_database` | Per-database aggregate vacuum stats |
+
+## Limitations
+
+- Must be loaded via `shared_preload_libraries`; it cannot be loaded on demand.
+- Tracking configuration (`add_track_*`, `remove_track_*`) is stored in a file and shared across all databases in the cluster.
diff --git a/contrib/ext_vacuum_statistics/expected/ext_vacuum_statistics.out b/contrib/ext_vacuum_statistics/expected/ext_vacuum_statistics.out
new file mode 100644
index 00000000000..89c9594dea8
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/expected/ext_vacuum_statistics.out
@@ -0,0 +1,52 @@
+-- ext_vacuum_statistics regression test
+
+-- Create extension
+CREATE EXTENSION ext_vacuum_statistics;
+
+-- Verify schema and views exist
+SELECT nspname FROM pg_namespace WHERE nspname = 'ext_vacuum_statistics';
+     nspname      
+------------------
+ ext_vacuum_statistics
+(1 row)
+
+-- Views should be queryable (may return empty if no vacuum has run)
+SELECT COUNT(*) >= 0 FROM ext_vacuum_statistics.pg_stats_vacuum_tables;
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT COUNT(*) >= 0 FROM ext_vacuum_statistics.pg_stats_vacuum_indexes;
+ ?column? 
+----------
+ t
+(1 row)
+
+SELECT COUNT(*) >= 0 FROM ext_vacuum_statistics.pg_stats_vacuum_database;
+ ?column? 
+----------
+ t
+(1 row)
+
+-- Verify views have expected columns
+SELECT COUNT(*) AS tables_cols FROM information_schema.columns
+WHERE table_schema = 'ext_vacuum_statistics' AND table_name = 'tables';
+ tables_cols 
+-------------
+          28
+(1 row)
+
+SELECT COUNT(*) AS indexes_cols FROM information_schema.columns
+WHERE table_schema = 'ext_vacuum_statistics' AND table_name = 'indexes';
+ indexes_cols 
+--------------
+            20
+(1 row)
+
+SELECT COUNT(*) AS database_cols FROM information_schema.columns
+WHERE table_schema = 'ext_vacuum_statistics' AND table_name = 'database';
+ database_cols 
+---------------
+             15
+(1 row)
diff --git a/contrib/ext_vacuum_statistics/expected/vacuum-extending-in-repetable-read.out b/contrib/ext_vacuum_statistics/expected/vacuum-extending-in-repetable-read.out
new file mode 100644
index 00000000000..6b381f9d232
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/expected/vacuum-extending-in-repetable-read.out
@@ -0,0 +1,52 @@
+unused step name: s2_delete
+Parsed test spec with 2 sessions
+
+starting permutation: s2_insert s2_print_vacuum_stats_table s1_begin_repeatable_read s2_update s2_insert_interrupt s2_vacuum s2_print_vacuum_stats_table s1_commit s2_checkpoint s2_vacuum s2_print_vacuum_stats_table
+step s2_insert: INSERT INTO test_vacuum_stat_isolation(id, ival) SELECT ival, ival%10 FROM generate_series(1,1000) As ival;
+step s2_print_vacuum_stats_table: 
+    SELECT
+        vt.relname, vt.tuples_deleted, vt.recently_dead_tuples, vt.missed_dead_tuples, vt.missed_dead_pages, vt.tuples_frozen
+    FROM ext_vacuum_statistics.pg_stats_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|tuples_frozen
+-------+--------------+--------------------+------------------+-----------------+-------------
+(0 rows)
+
+step s1_begin_repeatable_read: 
+    BEGIN transaction ISOLATION LEVEL REPEATABLE READ;
+    select count(ival) from test_vacuum_stat_isolation where id>900;
+
+count
+-----
+  100
+(1 row)
+
+step s2_update: UPDATE test_vacuum_stat_isolation SET ival = ival + 2 where id > 900;
+step s2_insert_interrupt: INSERT INTO test_vacuum_stat_isolation values (1,1);
+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, vt.tuples_frozen
+    FROM ext_vacuum_statistics.pg_stats_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|tuples_frozen
+--------------------------+--------------+--------------------+------------------+-----------------+-------------
+test_vacuum_stat_isolation|             0|                 100|                 0|                0|            0
+(1 row)
+
+step s1_commit: COMMIT;
+step s2_checkpoint: CHECKPOINT;
+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, vt.tuples_frozen
+    FROM ext_vacuum_statistics.pg_stats_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|tuples_frozen
+--------------------------+--------------+--------------------+------------------+-----------------+-------------
+test_vacuum_stat_isolation|           100|                 100|                 0|                0|          101
+(1 row)
+
diff --git a/contrib/ext_vacuum_statistics/ext_vacuum_statistics--1.0.sql b/contrib/ext_vacuum_statistics/ext_vacuum_statistics--1.0.sql
new file mode 100644
index 00000000000..aa3a9ec9699
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/ext_vacuum_statistics--1.0.sql
@@ -0,0 +1,272 @@
+/*-------------------------------------------------------------------------
+ *
+ * ext_vacuum_statistics--1.0.sql
+ *    Extended vacuum statistics via hook and custom storage
+ *
+ * This extension collects extended vacuum statistics via set_report_vacuum_hook
+ * and stores them in shared memory.
+ *
+ *-------------------------------------------------------------------------
+ */
+
+\echo Use "CREATE EXTENSION ext_vacuum_statistics" to load this file. \quit
+
+CREATE SCHEMA IF NOT EXISTS ext_vacuum_statistics;
+
+COMMENT ON SCHEMA ext_vacuum_statistics IS
+  'Extended vacuum statistics (heap, index, database)';
+
+-- Reset functions
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.extvac_reset_entry(
+    dboid oid,
+    relid oid,
+    type int4
+)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'extvac_reset_entry'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.extvac_reset_db_entry(dboid oid)
+RETURNS bigint
+AS 'MODULE_PATHNAME', 'extvac_reset_db_entry'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.vacuum_statistics_reset()
+RETURNS bigint
+AS 'MODULE_PATHNAME', 'vacuum_statistics_reset'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.shared_memory_size()
+RETURNS bigint
+AS 'MODULE_PATHNAME', 'extvac_shared_memory_size'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+COMMENT ON FUNCTION ext_vacuum_statistics.shared_memory_size() IS
+  'Total shared memory in bytes used by the extension for vacuum statistics.';
+
+-- Add/remove OIDs for tracking
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.add_track_database(dboid oid)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'evs_add_track_database'
+LANGUAGE C STRICT;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.remove_track_database(dboid oid)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'evs_remove_track_database'
+LANGUAGE C STRICT;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.add_track_relation(dboid oid, reloid oid)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'evs_add_track_relation'
+LANGUAGE C STRICT;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.remove_track_relation(dboid oid, reloid oid)
+RETURNS boolean
+AS 'MODULE_PATHNAME', 'evs_remove_track_relation'
+LANGUAGE C STRICT;
+
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.track_list()
+RETURNS TABLE(track_kind text, dboid oid, reloid oid)
+AS 'MODULE_PATHNAME', 'evs_track_list'
+LANGUAGE C STRICT;
+
+COMMENT ON FUNCTION ext_vacuum_statistics.track_list() IS
+  'List of database and relation OIDs for which vacuum statistics are collected.';
+
+-- Track-list mutation requires superuser or pg_read_all_stats; hide the
+-- functions from PUBLIC so the error is also produced for ordinary users
+-- before the C-level privilege check runs.
+REVOKE ALL ON FUNCTION ext_vacuum_statistics.add_track_database(oid) FROM PUBLIC;
+REVOKE ALL ON FUNCTION ext_vacuum_statistics.remove_track_database(oid) FROM PUBLIC;
+REVOKE ALL ON FUNCTION ext_vacuum_statistics.add_track_relation(oid, oid) FROM PUBLIC;
+REVOKE ALL ON FUNCTION ext_vacuum_statistics.remove_track_relation(oid, oid) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION ext_vacuum_statistics.add_track_database(oid) TO pg_read_all_stats;
+GRANT EXECUTE ON FUNCTION ext_vacuum_statistics.remove_track_database(oid) TO pg_read_all_stats;
+GRANT EXECUTE ON FUNCTION ext_vacuum_statistics.add_track_relation(oid, oid) TO pg_read_all_stats;
+GRANT EXECUTE ON FUNCTION ext_vacuum_statistics.remove_track_relation(oid, oid) TO pg_read_all_stats;
+
+-- Internal C function to fetch table vacuum stats
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.pg_stats_get_vacuum_tables(
+    IN  dboid oid,
+    IN  reloid oid,
+    OUT relid oid,
+    OUT total_blks_read bigint,
+    OUT total_blks_hit bigint,
+    OUT total_blks_dirtied bigint,
+    OUT total_blks_written bigint,
+    OUT wal_records bigint,
+    OUT wal_fpi bigint,
+    OUT wal_bytes numeric,
+    OUT blk_read_time double precision,
+    OUT blk_write_time double precision,
+    OUT delay_time double precision,
+    OUT total_time double precision,
+    OUT wraparound_failsafe_count integer,
+    OUT rel_blks_read bigint,
+    OUT rel_blks_hit bigint,
+    OUT tuples_deleted bigint,
+    OUT pages_scanned bigint,
+    OUT pages_removed bigint,
+    OUT vm_new_frozen_pages bigint,
+    OUT vm_new_visible_pages bigint,
+    OUT vm_new_visible_frozen_pages bigint,
+    OUT tuples_frozen bigint,
+    OUT recently_dead_tuples bigint,
+    OUT index_vacuum_count bigint,
+    OUT missed_dead_pages bigint,
+    OUT missed_dead_tuples bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_stats_get_vacuum_tables'
+LANGUAGE C STRICT STABLE;
+
+-- Internal C function to fetch index vacuum stats
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.pg_stats_get_vacuum_indexes(
+    IN  dboid oid,
+    IN  reloid oid,
+    OUT relid oid,
+    OUT total_blks_read bigint,
+    OUT total_blks_hit bigint,
+    OUT total_blks_dirtied bigint,
+    OUT total_blks_written bigint,
+    OUT wal_records bigint,
+    OUT wal_fpi bigint,
+    OUT wal_bytes numeric,
+    OUT blk_read_time double precision,
+    OUT blk_write_time double precision,
+    OUT delay_time double precision,
+    OUT total_time double precision,
+    OUT wraparound_failsafe_count integer,
+    OUT rel_blks_read bigint,
+    OUT rel_blks_hit bigint,
+    OUT tuples_deleted bigint,
+    OUT pages_deleted bigint
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_stats_get_vacuum_indexes'
+LANGUAGE C STRICT STABLE;
+
+-- Internal C function to fetch database vacuum stats
+CREATE OR REPLACE FUNCTION ext_vacuum_statistics.pg_stats_get_vacuum_database(
+    IN  dboid oid,
+    OUT dbid oid,
+    OUT total_blks_read bigint,
+    OUT total_blks_hit bigint,
+    OUT total_blks_dirtied bigint,
+    OUT total_blks_written bigint,
+    OUT wal_records bigint,
+    OUT wal_fpi bigint,
+    OUT wal_bytes numeric,
+    OUT blk_read_time double precision,
+    OUT blk_write_time double precision,
+    OUT delay_time double precision,
+    OUT total_time double precision,
+    OUT wraparound_failsafe_count integer,
+    OUT interrupts_count integer
+)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'pg_stats_get_vacuum_database'
+LANGUAGE C STRICT STABLE;
+
+-- View: vacuum statistics per table (heap)
+CREATE VIEW ext_vacuum_statistics.pg_stats_vacuum_tables AS
+SELECT
+  rel.oid AS relid,
+  ns.nspname AS schema,
+  rel.relname AS relname,
+  db.datname AS dbname,
+  stats.total_blks_read,
+  stats.total_blks_hit,
+  stats.total_blks_dirtied,
+  stats.total_blks_written,
+  stats.wal_records,
+  stats.wal_fpi,
+  stats.wal_bytes,
+  stats.blk_read_time,
+  stats.blk_write_time,
+  stats.delay_time,
+  stats.total_time,
+  stats.wraparound_failsafe_count,
+  stats.rel_blks_read,
+  stats.rel_blks_hit,
+  stats.tuples_deleted,
+  stats.pages_scanned,
+  stats.pages_removed,
+  stats.vm_new_frozen_pages,
+  stats.vm_new_visible_pages,
+  stats.vm_new_visible_frozen_pages,
+  stats.tuples_frozen,
+  stats.recently_dead_tuples,
+  stats.index_vacuum_count,
+  stats.missed_dead_pages,
+  stats.missed_dead_tuples
+FROM pg_database db,
+     pg_class rel,
+     pg_namespace ns,
+     LATERAL ext_vacuum_statistics.pg_stats_get_vacuum_tables(db.oid, rel.oid) stats
+WHERE db.datname = current_database()
+  AND rel.relkind = 'r'
+  AND rel.relnamespace = ns.oid
+  AND rel.oid = stats.relid;
+
+COMMENT ON VIEW ext_vacuum_statistics.pg_stats_vacuum_tables IS
+  'Extended vacuum statistics per table (heap)';
+
+-- View: vacuum statistics per index
+CREATE VIEW ext_vacuum_statistics.pg_stats_vacuum_indexes AS
+SELECT
+  rel.oid AS indexrelid,
+  ns.nspname AS schema,
+  rel.relname AS indexrelname,
+  db.datname AS dbname,
+  stats.total_blks_read,
+  stats.total_blks_hit,
+  stats.total_blks_dirtied,
+  stats.total_blks_written,
+  stats.wal_records,
+  stats.wal_fpi,
+  stats.wal_bytes,
+  stats.blk_read_time,
+  stats.blk_write_time,
+  stats.delay_time,
+  stats.total_time,
+  stats.wraparound_failsafe_count,
+  stats.rel_blks_read,
+  stats.rel_blks_hit,
+  stats.tuples_deleted,
+  stats.pages_deleted
+FROM pg_database db,
+     pg_class rel,
+     pg_namespace ns,
+     LATERAL ext_vacuum_statistics.pg_stats_get_vacuum_indexes(db.oid, rel.oid) stats
+WHERE db.datname = current_database()
+  AND rel.relkind = 'i'
+  AND rel.relnamespace = ns.oid
+  AND rel.oid = stats.relid;
+
+COMMENT ON VIEW ext_vacuum_statistics.pg_stats_vacuum_indexes IS
+  'Extended vacuum statistics per index';
+
+-- View: vacuum statistics per database (aggregate)
+CREATE VIEW ext_vacuum_statistics.pg_stats_vacuum_database AS
+SELECT
+  db.oid AS dboid,
+  db.datname AS dbname,
+  stats.total_blks_read AS db_blks_read,
+  stats.total_blks_hit AS db_blks_hit,
+  stats.total_blks_dirtied AS db_blks_dirtied,
+  stats.total_blks_written AS db_blks_written,
+  stats.wal_records AS db_wal_records,
+  stats.wal_fpi AS db_wal_fpi,
+  stats.wal_bytes AS db_wal_bytes,
+  stats.blk_read_time AS db_blk_read_time,
+  stats.blk_write_time AS db_blk_write_time,
+  stats.delay_time AS db_delay_time,
+  stats.total_time AS db_total_time,
+  stats.wraparound_failsafe_count AS db_wraparound_failsafe_count,
+  stats.interrupts_count
+FROM pg_database db
+LEFT JOIN LATERAL ext_vacuum_statistics.pg_stats_get_vacuum_database(db.oid) stats ON db.oid = stats.dbid;
+
+COMMENT ON VIEW ext_vacuum_statistics.pg_stats_vacuum_database IS
+  'Extended vacuum statistics per database (aggregate)';
diff --git a/contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf b/contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf
new file mode 100644
index 00000000000..9b711487623
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf
@@ -0,0 +1,2 @@
+# Config for ext_vacuum_statistics regression tests
+shared_preload_libraries = 'ext_vacuum_statistics'
diff --git a/contrib/ext_vacuum_statistics/ext_vacuum_statistics.control b/contrib/ext_vacuum_statistics/ext_vacuum_statistics.control
new file mode 100644
index 00000000000..518350a64b7
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/ext_vacuum_statistics.control
@@ -0,0 +1,5 @@
+# ext_vacuum_statistics extension
+comment = 'Extended vacuum statistics via hook (requires shared_preload_libraries)'
+default_version = '1.0'
+relocatable = true
+module_pathname = '$libdir/ext_vacuum_statistics'
diff --git a/contrib/ext_vacuum_statistics/meson.build b/contrib/ext_vacuum_statistics/meson.build
new file mode 100644
index 00000000000..72338baa500
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/meson.build
@@ -0,0 +1,41 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+#
+# ext_vacuum_statistics - extended vacuum statistics via hook
+# Requires shared_preload_libraries = 'ext_vacuum_statistics'
+
+ext_vacuum_statistics_sources = files(
+  'vacuum_statistics.c',
+)
+
+ext_vacuum_statistics = shared_module('ext_vacuum_statistics',
+  ext_vacuum_statistics_sources,
+  kwargs: contrib_mod_args + {
+    'dependencies': contrib_mod_args['dependencies'],
+  },
+)
+contrib_targets += ext_vacuum_statistics
+
+install_data(
+  'ext_vacuum_statistics.control',
+  'ext_vacuum_statistics--1.0.sql',
+  kwargs: contrib_data_args,
+)
+
+tests += {
+  'name': 'ext_vacuum_statistics',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'isolation': {
+    'specs': [
+      'vacuum-extending-in-repetable-read',
+    ],
+    'regress_args': ['--temp-config', files('ext_vacuum_statistics.conf')],
+    'runningcheck': false,
+  },
+  'tap': {
+    'tests': [
+      't/052_vacuum_extending_basic_test.pl',
+      't/053_vacuum_extending_freeze_test.pl',
+    ],
+  },
+}
diff --git a/contrib/ext_vacuum_statistics/specs/vacuum-extending-in-repetable-read.spec b/contrib/ext_vacuum_statistics/specs/vacuum-extending-in-repetable-read.spec
new file mode 100644
index 00000000000..4891e248cca
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/specs/vacuum-extending-in-repetable-read.spec
@@ -0,0 +1,59 @@
+# Test for checking recently_dead_tuples, tuples_deleted and frozen tuples in ext_vacuum_statistics.pg_stats_vacuum_tables.
+# recently_dead_tuples values are counted when vacuum hasn't cleared tuples because they were deleted recently.
+# recently_dead_tuples aren't increased after releasing lock compared with tuples_deleted, which increased
+# by the value of the cleared tuples that the vacuum managed to clear.
+
+setup
+{
+    CREATE TABLE test_vacuum_stat_isolation(id int, ival int) WITH (autovacuum_enabled = off);
+    CREATE EXTENSION ext_vacuum_statistics;
+    SET track_io_timing = on;
+}
+
+teardown
+{
+    DROP EXTENSION ext_vacuum_statistics CASCADE;
+    DROP TABLE test_vacuum_stat_isolation CASCADE;
+    RESET track_io_timing;
+}
+
+session s1
+setup {
+    SET track_io_timing = on;
+}
+step s1_begin_repeatable_read {
+    BEGIN transaction ISOLATION LEVEL REPEATABLE READ;
+    select count(ival) from test_vacuum_stat_isolation where id>900;
+}
+step s1_commit { COMMIT; }
+
+session s2
+setup {
+    SET track_io_timing = on;
+}
+step s2_insert                  { INSERT INTO test_vacuum_stat_isolation(id, ival) SELECT ival, ival%10 FROM generate_series(1,1000) As ival; }
+step s2_update                  { UPDATE test_vacuum_stat_isolation SET ival = ival + 2 where id > 900; }
+step s2_delete                  { DELETE FROM test_vacuum_stat_isolation where id > 900; }
+step s2_insert_interrupt        { INSERT INTO test_vacuum_stat_isolation values (1,1); }
+step s2_vacuum                  { VACUUM test_vacuum_stat_isolation; }
+step s2_checkpoint              { CHECKPOINT; }
+step s2_print_vacuum_stats_table
+{
+    SELECT
+        vt.relname, vt.tuples_deleted, vt.recently_dead_tuples, vt.missed_dead_tuples, vt.missed_dead_pages, vt.tuples_frozen
+    FROM ext_vacuum_statistics.pg_stats_vacuum_tables vt, pg_class c
+    WHERE vt.relname = 'test_vacuum_stat_isolation' AND vt.relid = c.oid;
+}
+
+permutation
+    s2_insert
+    s2_print_vacuum_stats_table
+    s1_begin_repeatable_read
+    s2_update
+    s2_insert_interrupt
+    s2_vacuum
+    s2_print_vacuum_stats_table
+    s1_commit
+    s2_checkpoint
+    s2_vacuum
+    s2_print_vacuum_stats_table
diff --git a/contrib/ext_vacuum_statistics/t/052_vacuum_extending_basic_test.pl b/contrib/ext_vacuum_statistics/t/052_vacuum_extending_basic_test.pl
new file mode 100644
index 00000000000..9463d5145f4
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/t/052_vacuum_extending_basic_test.pl
@@ -0,0 +1,780 @@
+# Copyright (c) 2025 PostgreSQL Global Development Group
+# Test cumulative vacuum stats system using TAP
+#
+# This test validates the accuracy and behavior of cumulative vacuum statistics
+# across heap tables, indexes, and databases using:
+#
+#   • ext_vacuum_statistics.pg_stats_vacuum_tables
+#   • ext_vacuum_statistics.pg_stats_vacuum_indexes
+#   • ext_vacuum_statistics.pg_stats_vacuum_database
+#
+# A polling helper function repeatedly checks the stats views until expected
+# deltas appear or a configurable timeout expires. This guarantees that
+# stats-collector propagation delays do not lead to flaky test behavior.
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+#------------------------------------------------------------------------------
+# Test harness setup
+#------------------------------------------------------------------------------
+
+my $node = PostgreSQL::Test::Cluster->new('stat_vacuum');
+$node->init;
+
+# Configure the server: preload extension and logging level
+$node->append_conf('postgresql.conf', q{
+    shared_preload_libraries = 'ext_vacuum_statistics'
+    log_min_messages = notice
+});
+
+my $stderr;
+my $base_stats;
+my $wals;
+my $ibase_stats;
+my $iwals;
+
+$node->start(
+    '>' => \$base_stats,
+	'2>' => \$stderr
+);
+
+#------------------------------------------------------------------------------
+# Database creation and initialization
+#------------------------------------------------------------------------------
+
+$node->safe_psql('postgres', q{
+    CREATE DATABASE statistic_vacuum_database_regression;
+    CREATE EXTENSION ext_vacuum_statistics;
+});
+# Main test database name and number of rows to insert
+my $dbname   = 'statistic_vacuum_database_regression';
+my $size_tab = 1000;
+
+# Enable required session settings and force the stats collector to flush next
+$node->safe_psql($dbname, q{
+    SET track_functions = 'all';
+    SELECT pg_stat_force_next_flush();
+});
+
+#------------------------------------------------------------------------------
+# Create test table and populate it
+#------------------------------------------------------------------------------
+
+$node->safe_psql(
+    $dbname,
+    "CREATE EXTENSION ext_vacuum_statistics;
+     CREATE TABLE vestat (x int PRIMARY KEY)
+         WITH (autovacuum_enabled = off, fillfactor = 10);
+     INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     ANALYZE vestat;"
+);
+
+#------------------------------------------------------------------------------
+# Timing parameters for polling loops
+#------------------------------------------------------------------------------
+
+my $timeout    = 30;     # overall wait timeout in seconds
+my $interval   = 0.015;  # poll interval in seconds (15 ms)
+my $start_time = time();
+my $updated    = 0;
+
+#------------------------------------------------------------------------------
+# wait_for_vacuum_stats
+#
+# Polls ext_vacuum_statistics.pg_stats_vacuum_tables and ext_vacuum_statistics.pg_stats_vacuum_indexes until both the
+# table-level and index-level counters exceed the provided baselines, or until
+# the configured timeout elapses.
+#
+# Expected named args (baseline values):
+#   tab_tuples_deleted
+#   tab_wal_records
+#   idx_tuples_deleted
+#   idx_wal_records
+#
+# Returns: 1 if the condition is met before timeout, 0 otherwise.
+#------------------------------------------------------------------------------
+
+sub wait_for_vacuum_stats {
+    my (%args) = @_;
+    my $tab_tuples_deleted = ($args{tab_tuples_deleted} or 0);
+    my $tab_wal_records    = ($args{tab_wal_records} or 0);
+    my $idx_tuples_deleted = ($args{idx_tuples_deleted} or 0);
+    my $idx_wal_records    = ($args{idx_wal_records} or 0);
+
+    my $start = time();
+    while ((time() - $start) < $timeout) {
+
+        my $result_query = $node->safe_psql(
+            $dbname,
+            "VACUUM vestat;
+             SELECT
+                (SELECT (tuples_deleted > $tab_tuples_deleted AND wal_records > $tab_wal_records)
+                  FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+                  WHERE relname = 'vestat')
+                AND
+                (SELECT (tuples_deleted > $idx_tuples_deleted AND wal_records > $idx_wal_records)
+                  FROM ext_vacuum_statistics.pg_stats_vacuum_indexes
+                  WHERE indexrelname = 'vestat_pkey');"
+        );
+
+        return 1 if ($result_query eq 't');
+
+        sleep($interval);
+    }
+
+    return 0;
+}
+
+#------------------------------------------------------------------------------
+# Variables to hold vacuum-stat snapshots for later comparisons
+#------------------------------------------------------------------------------
+
+my $vm_new_visible_frozen_pages = 0;
+my $tuples_deleted = 0;
+my $pages_scanned = 0;
+my $pages_removed = 0;
+my $wal_records = 0;
+my $wal_bytes = 0;
+my $wal_fpi = 0;
+
+my $index_tuples_deleted = 0;
+my $index_pages_deleted = 0;
+my $index_wal_records = 0;
+my $index_wal_bytes = 0;
+my $index_wal_fpi = 0;
+
+my $vm_new_visible_frozen_pages_prev = 0;
+my $tuples_deleted_prev = 0;
+my $pages_scanned_prev = 0;
+my $pages_removed_prev = 0;
+my $wal_records_prev = 0;
+my $wal_bytes_prev = 0;
+my $wal_fpi_prev = 0;
+
+my $index_tuples_deleted_prev = 0;
+my $index_pages_deleted_prev = 0;
+my $index_wal_records_prev = 0;
+my $index_wal_bytes_prev = 0;
+my $index_wal_fpi_prev = 0;
+
+#------------------------------------------------------------------------------
+# fetch_vacuum_stats
+#
+# Reads current values of relevant vacuum counters for the test table and its
+# primary index, storing them in package variables for subsequent comparisons.
+#------------------------------------------------------------------------------
+
+sub fetch_vacuum_stats {
+    # fetch actual base vacuum statistics
+    my $base_statistics = $node->safe_psql(
+        $dbname,
+        "SELECT vm_new_visible_frozen_pages, tuples_deleted, pages_scanned, pages_removed, wal_records, wal_bytes, wal_fpi
+           FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+          WHERE relname = 'vestat';"
+    );
+
+    $base_statistics =~ s/\s*\|\s*/ /g;   # transform " | " into space
+    ($vm_new_visible_frozen_pages, $tuples_deleted, $pages_scanned, $pages_removed, $wal_records, $wal_bytes, $wal_fpi)
+        = split /\s+/, $base_statistics;
+
+    # --- index stats ---
+    my $index_base_statistics = $node->safe_psql(
+        $dbname,
+        "SELECT tuples_deleted, pages_deleted, wal_records, wal_bytes, wal_fpi
+           FROM ext_vacuum_statistics.pg_stats_vacuum_indexes
+          WHERE indexrelname = 'vestat_pkey';"
+    );
+
+    $index_base_statistics =~ s/\s*\|\s*/ /g;   # transform " | " into space
+    ($index_tuples_deleted, $index_pages_deleted, $index_wal_records, $index_wal_bytes, $index_wal_fpi)
+        = split /\s+/, $index_base_statistics;
+}
+
+#------------------------------------------------------------------------------
+# save_vacuum_stats
+#
+# Save current values (previously fetched by fetch_vacuum_stats) so that we
+# later fetch new values and compare them.
+#------------------------------------------------------------------------------
+sub save_vacuum_stats {
+    $vm_new_visible_frozen_pages_prev = $vm_new_visible_frozen_pages;
+    $tuples_deleted_prev = $tuples_deleted;
+    $pages_scanned_prev = $pages_scanned;
+    $pages_removed_prev = $pages_removed;
+    $wal_records_prev = $wal_records;
+    $wal_bytes_prev = $wal_bytes;
+    $wal_fpi_prev = $wal_fpi;
+
+    $index_tuples_deleted_prev = $index_tuples_deleted;
+    $index_pages_deleted_prev = $index_pages_deleted;
+    $index_wal_records_prev = $index_wal_records;
+    $index_wal_bytes_prev = $index_wal_bytes;
+    $index_wal_fpi_prev = $index_wal_fpi;
+}
+
+#------------------------------------------------------------------------------
+# print_vacuum_stats_on_error
+#
+# Print values in case of an error
+#------------------------------------------------------------------------------
+sub print_vacuum_stats_on_error {
+    diag(
+            "Statistics in the failed test\n" .
+            "Table statistics:\n" .
+            "  Before test:\n" .
+            "    vm_new_visible_frozen_pages = $vm_new_visible_frozen_pages_prev\n" .
+            "    tuples_deleted    = $tuples_deleted_prev\n" .
+            "    pages_scanned     = $pages_scanned_prev\n" .
+            "    pages_removed     = $pages_removed_prev\n" .
+            "    wal_records       = $wal_records_prev\n" .
+            "    wal_bytes         = $wal_bytes_prev\n" .
+            "    wal_fpi           = $wal_fpi_prev\n" .
+            "  After test:\n" .
+            "    vm_new_visible_frozen_pages = $vm_new_visible_frozen_pages\n" .
+            "    tuples_deleted    = $tuples_deleted\n" .
+            "    pages_scanned     = $pages_scanned\n" .
+            "    pages_removed     = $pages_removed\n" .
+            "    wal_records       = $wal_records\n" .
+            "    wal_bytes         = $wal_bytes\n" .
+            "    wal_fpi           = $wal_fpi\n" .
+            "Index statistics:\n" .
+            "   Before test:\n" .
+            "    tuples_deleted    = $index_tuples_deleted_prev\n" .
+            "    pages_deleted     = $index_pages_deleted_prev\n" .
+            "    wal_records       = $index_wal_records_prev\n" .
+            "    wal_bytes         = $index_wal_bytes_prev\n" .
+            "    wal_fpi           = $index_wal_fpi_prev\n" .
+            "  After test:\n" .
+            "    tuples_deleted    = $index_tuples_deleted\n" .
+            "    pages_deleted     = $index_pages_deleted\n" .
+            "    wal_records       = $index_wal_records\n" .
+            "    wal_bytes         = $index_wal_bytes\n" .
+            "    wal_fpi           = $index_wal_fpi\n"
+    );
+};
+
+sub fetch_error_base_db_vacuum_statistics {
+    my (%args) = @_;
+
+    # Validate presence of required args (allow 0 as valid numeric baseline)
+    die "database name required"
+      unless exists $args{database_name} && defined $args{database_name};
+    my $database_name       = $args{database_name};
+
+    # fetch actual base database vacuum statistics
+    my $base_statistics = $node->safe_psql(
+    $database_name,
+    "SELECT db_blks_hit, db_blks_dirtied,
+            db_blks_written, db_wal_records,
+            db_wal_fpi, db_wal_bytes
+       FROM ext_vacuum_statistics.pg_stats_vacuum_database, pg_database
+      WHERE pg_database.datname = '$dbname'
+            AND pg_database.oid = ext_vacuum_statistics.pg_stats_vacuum_database.dboid;"
+    );
+    $base_statistics =~ s/\s*\|\s*/ /g;   # transform " | " in space
+    my ($db_blks_hit, $total_blks_dirtied, $total_blks_written,
+        $wal_records, $wal_fpi, $wal_bytes) = split /\s+/, $base_statistics;
+
+    diag(
+            "BASE STATS MISMATCH FOR DATABASE $dbname:\n" .
+            "    db_blks_hit        = $db_blks_hit\n" .
+            "    total_blks_dirtied = $total_blks_dirtied\n" .
+            "    total_blks_written = $total_blks_written\n" .
+            "    wal_records        = $wal_records\n" .
+            "    wal_fpi            = $wal_fpi\n" .
+            "    wal_bytes          = $wal_bytes\n"
+    );
+}
+
+
+#------------------------------------------------------------------------------
+# Test 1: Delete half the rows, run VACUUM, and wait for stats to advance
+#------------------------------------------------------------------------------
+subtest 'Test 1: Delete half the rows, run VACUUM' => sub
+{
+
+$node->safe_psql($dbname, "DELETE FROM vestat WHERE x % 2 = 0;");
+$node->safe_psql($dbname, "VACUUM vestat;");
+
+# Poll the stats view until expected deltas appear or timeout
+$updated = wait_for_vacuum_stats(
+    tab_tuples_deleted => 0,
+    tab_wal_records => 0,
+    idx_tuples_deleted => 0,
+    idx_wal_records => 0,
+);
+ok($updated, 'vacuum stats updated after vacuuming half-deleted table (tuples_deleted and wal_fpi advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds after vacuuming half-deleted table";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted > $tuples_deleted_prev, 'table tuples_deleted has increased');
+ok($pages_scanned > $pages_scanned_prev, 'table pages_scanned has increased');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi > $wal_fpi_prev, 'table wal_fpi has increased');
+
+ok($index_pages_deleted == $index_pages_deleted_prev, 'index pages_deleted stay the same');
+ok($index_tuples_deleted > $index_tuples_deleted_prev, 'index tuples_deleted has increased');
+ok($index_wal_records > $index_wal_records_prev, 'index wal_records has increased');
+ok($index_wal_bytes > $index_wal_bytes_prev, 'index wal_bytes has increased');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 2: Delete all rows, run VACUUM, and wait for stats to advance
+#------------------------------------------------------------------------------
+subtest 'Test 2: Delete all rows, run VACUUM' => sub
+{
+save_vacuum_stats();
+
+$node->safe_psql($dbname, "DELETE FROM vestat;");
+$node->safe_psql($dbname, "VACUUM vestat;");
+
+$updated = wait_for_vacuum_stats(
+    tab_tuples_deleted => $tuples_deleted_prev,
+    tab_wal_records => $wal_records_prev,
+    idx_tuples_deleted => $index_tuples_deleted_prev,
+    idx_wal_records => $index_wal_records_prev,
+);
+
+ok($updated, 'vacuum stats updated after vacuuming all-deleted table (tuples_deleted and wal_records advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds after vacuuming all-deleted table";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages > $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages has increased');
+ok($tuples_deleted > $tuples_deleted_prev, 'table tuples_deleted has increased');
+ok($pages_scanned > $pages_scanned_prev, 'table pages_scanned has increased');
+ok($pages_removed > $pages_removed_prev, 'table pages_removed has increased');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi > 0, 'table wal_fpi has increased');
+
+ok($index_pages_deleted > $index_pages_deleted_prev, 'index pages_deleted has increased');
+ok($index_tuples_deleted > $index_tuples_deleted_prev, 'index tuples_deleted has increased');
+ok($index_wal_records > $index_wal_records_prev, 'index wal_records has increased');
+ok($index_wal_bytes > $index_wal_bytes_prev, 'index wal_bytes has increased');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 3: Test VACUUM FULL — it should not report to the stats collector
+#------------------------------------------------------------------------------
+subtest 'Test 3: Test VACUUM FULL — it should not report to the stats collector' => sub
+{
+save_vacuum_stats();
+
+$node->safe_psql(
+    $dbname,
+    "INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     CHECKPOINT;
+     DELETE FROM vestat;
+     VACUUM FULL vestat;"
+);
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted == $tuples_deleted_prev, 'table tuples_deleted stay the same');
+ok($pages_scanned == $pages_scanned_prev, 'table pages_scanned stay the same');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records == $wal_records_prev, 'table wal_records stay the same');
+ok($wal_bytes == $wal_bytes_prev, 'table wal_bytes stay the same');
+ok($wal_fpi == $wal_fpi_prev, 'table wal_fpi stay the same');
+
+ok($index_pages_deleted == $index_pages_deleted_prev, 'index pages_deleted stay the same');
+ok($index_tuples_deleted == $index_tuples_deleted_prev, 'index tuples_deleted stay the same');
+ok($index_wal_records == $index_wal_records_prev, 'index wal_records stay the same');
+ok($index_wal_bytes == $index_wal_bytes_prev, 'index wal_bytes stay the same');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 4: Update table, checkpoint, and VACUUM to provoke WAL/FPI accounting
+#------------------------------------------------------------------------------
+subtest 'Test 4: Update table, checkpoint, and VACUUM to provoke WAL/FPI accounting' => sub
+{
+
+save_vacuum_stats();
+
+$node->safe_psql(
+    $dbname,
+    "INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     CHECKPOINT;
+     UPDATE vestat SET x = x + 1000;
+     VACUUM vestat;"
+);
+
+$updated = wait_for_vacuum_stats(
+    tab_tuples_deleted => $tuples_deleted_prev,
+    tab_wal_records => $wal_records_prev,
+    idx_tuples_deleted => $index_tuples_deleted_prev,
+    idx_wal_records => $index_wal_records_prev,
+);
+
+ok($updated, 'vacuum stats updated after updating tuples in the table (tuples_deleted and wal_records advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted > $tuples_deleted_prev, 'table tuples_deleted has increased');
+ok($pages_scanned > $pages_scanned_prev, 'table pages_scanned has increased');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi > $wal_fpi_prev, 'table wal_fpi has increased');
+
+ok($index_pages_deleted > $index_pages_deleted_prev, 'index pages_deleted has increased');
+ok($index_tuples_deleted > $index_tuples_deleted_prev, 'index tuples_deleted has increased');
+ok($index_wal_records > $index_wal_records_prev, 'index wal_records has increased');
+ok($index_wal_bytes > $index_wal_bytes_prev, 'index wal_bytes has increased');
+ok($index_wal_fpi > $index_wal_fpi_prev, 'index wal_fpi has increased');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 5: Update table, trancate and vacuuming
+#------------------------------------------------------------------------------
+subtest 'Test 5: Update table, trancate and vacuuming' => sub
+{
+
+save_vacuum_stats();
+
+$node->safe_psql(
+    $dbname,
+    "INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     UPDATE vestat SET x = x + 1000;"
+);
+$node->safe_psql($dbname, "TRUNCATE vestat;");
+$node->safe_psql($dbname, "CHECKPOINT;");
+$node->safe_psql($dbname, "VACUUM vestat;");
+
+$updated = wait_for_vacuum_stats(
+    tab_wal_records => $wal_records_prev,
+);
+
+ok($updated, 'vacuum stats updated after updating tuples and trancation in the table (wal_records advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted == $tuples_deleted_prev, 'table tuples_deleted stay the same');
+ok($pages_scanned == $pages_scanned_prev, 'table pages_scanned stay the same');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi == $wal_fpi_prev, 'table wal_fpi stay the same');
+
+ok($index_pages_deleted == $index_pages_deleted_prev, 'index pages_deleted stay the same');
+ok($index_tuples_deleted == $index_tuples_deleted_prev, 'index tuples_deleted stay the same');
+ok($index_wal_records == $index_wal_records_prev, 'index wal_records stay the same');
+ok($index_wal_bytes == $index_wal_bytes_prev, 'index wal_bytes stay the same');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 6: Delete all tuples from table, trancate, and vacuuming
+#------------------------------------------------------------------------------
+subtest 'Test 6: Delete all tuples from table, trancate, and vacuuming' => sub
+{
+
+save_vacuum_stats();
+
+$node->safe_psql(
+    $dbname,
+    "INSERT INTO vestat SELECT x FROM generate_series(1, $size_tab) AS g(x);
+     DELETE FROM vestat;
+     TRUNCATE vestat;
+     CHECKPOINT;
+     VACUUM vestat;"
+);
+
+$updated = wait_for_vacuum_stats(
+    tab_wal_records => $wal_records,
+);
+
+ok($updated, 'vacuum stats updated after deleting all tuples and trancation in the table (wal_records advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics update after $timeout seconds";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($tuples_deleted == $tuples_deleted_prev, 'table tuples_deleted stay the same');
+ok($pages_scanned == $pages_scanned_prev, 'table pages_scanned stay the same');
+ok($pages_removed == $pages_removed_prev, 'table pages_removed stay the same');
+ok($wal_records > $wal_records_prev, 'table wal_records has increased');
+ok($wal_bytes > $wal_bytes_prev, 'table wal_bytes has increased');
+ok($wal_fpi == $wal_fpi_prev, 'table wal_fpi stay the same');
+
+ok($index_pages_deleted == $index_pages_deleted_prev, 'index pages_deleted stay the same');
+ok($index_tuples_deleted == $index_tuples_deleted_prev, 'index tuples_deleted stay the same');
+ok($index_wal_records == $index_wal_records_prev, 'index wal_records stay the same');
+ok($index_wal_bytes == $index_wal_bytes_prev, 'index wal_bytes stay the same');
+ok($index_wal_fpi == $index_wal_fpi_prev, 'index wal_fpi stay the same');
+
+} or print_vacuum_stats_on_error();
+
+my $dboid = $node->safe_psql(
+    $dbname,
+    "SELECT oid FROM pg_database WHERE datname = current_database();"
+);
+
+#-------------------------------------------------------------------------------------------------------
+# Test 7: Check if we return single vacuum statistics for particular relation from the current database
+#-------------------------------------------------------------------------------------------------------
+subtest 'Test 7: Check if we return vacuum statistics from the current database' => sub
+{
+save_vacuum_stats();
+
+my $reloid = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT oid FROM pg_class WHERE relname = 'vestat';
+    }
+);
+
+# Check if we can get vacuum statistics of particular heap relation in the current database
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_get_vacuum_tables((SELECT oid FROM pg_database WHERE datname = current_database()), $reloid);"
+);
+is($base_stats, 1, 'heap vacuum stats return from the current relation and database as expected');
+
+$reloid = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT oid FROM pg_class WHERE relname = 'vestat_pkey';
+    }
+);
+
+# Check if we can get vacuum statistics of particular index relation in the current database
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_get_vacuum_indexes((SELECT oid FROM pg_database WHERE datname = current_database()), $reloid);"
+);
+is($base_stats, 1, 'index vacuum stats return from the current relation and database as expected');
+
+# Check if we return empty results if vacuum statistics with particular oid doesn't exist
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_get_vacuum_tables((SELECT oid FROM pg_database WHERE datname = current_database()), 1);"
+);
+is($base_stats, 0, 'table vacuum stats return no rows, as expected');
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_get_vacuum_indexes((SELECT oid FROM pg_database WHERE datname = current_database()), 1);"
+);
+is($base_stats, 0, 'index vacuum stats return no rows, as expected');
+
+# Check if we can get vacuum statistics of all relations in the current database
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) > 0 FROM ext_vacuum_statistics.pg_stats_vacuum_tables;"
+);
+ok($base_stats eq 't', 'vacuum stats per all heap objects available');
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT count(*) > 0 FROM ext_vacuum_statistics.pg_stats_vacuum_indexes;"
+);
+ok($base_stats eq 't', 'vacuum stats per all index objects available');
+};
+
+#------------------------------------------------------------------------------
+# Test 8: Check relation-level vacuum statistics from another database
+#------------------------------------------------------------------------------
+subtest 'Test 8: Check relation-level vacuum statistics from another database' => sub
+{
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*)
+    FROM ext_vacuum_statistics.pg_stats_vacuum_indexes
+    WHERE indexrelname = 'vestat_pkey';"
+);
+is($base_stats, 0, 'check the printing index vacuum extended statistics from another database are not available');
+
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*)
+    FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+    WHERE relname = 'vestat';"
+);
+is($base_stats, 0, 'check the printing heap vacuum extended statistics from another database are not available');
+
+# Check that relations from another database are not visible in the view when querying from postgres
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'vestat';"
+);
+is($base_stats, 0, 'vacuum stats per all tables objects from another database are not available as expected');
+
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*) FROM ext_vacuum_statistics.pg_stats_vacuum_indexes WHERE indexrelname = 'vestat_pkey';"
+);
+is($base_stats, 0, 'vacuum stats per all index objects from another database are not available as expected');
+};
+
+#--------------------------------------------------------------------------------------
+# Test 9: Check database-level vacuum statistics from the current and another database
+#--------------------------------------------------------------------------------------
+subtest 'Test 9: Check database-level vacuum statistics from the current and another database' => sub
+{
+my $db_blk_hit = 0;
+my $total_blks_dirtied = 0;
+my $total_blks_written = 0;
+my $wal_records = 0;
+my $wal_fpi = 0;
+my $wal_bytes = 0;
+$base_stats = $node->safe_psql(
+    $dbname,
+    "SELECT db_blks_hit, db_blks_dirtied,
+            db_blks_written, db_wal_records,
+            db_wal_fpi, db_wal_bytes
+     FROM ext_vacuum_statistics.pg_stats_vacuum_database, pg_database
+     WHERE pg_database.datname = '$dbname'
+            AND pg_database.oid = ext_vacuum_statistics.pg_stats_vacuum_database.dboid;"
+);
+$base_stats =~ s/\s*\|\s*/ /g;   # transform " | " into space
+    ($db_blk_hit, $total_blks_dirtied, $total_blks_written, $wal_records, $wal_fpi, $wal_bytes)
+        = split /\s+/, $base_stats;
+
+ok($db_blk_hit > 0, 'db_blks_hit is more than 0');
+ok($total_blks_dirtied > 0, 'total_blks_dirtied is more than 0');
+ok($total_blks_written > 0, 'total_blks_written is more than 0');
+ok($wal_records > 0, 'wal_records is more than 0');
+ok($wal_fpi > 0, 'wal_fpi is more than 0');
+ok($wal_bytes > 0, 'wal_bytes is more than 0');
+
+$base_stats = $node->safe_psql(
+    'postgres',
+    "SELECT count(*) = 1
+     FROM ext_vacuum_statistics.pg_stats_vacuum_database, pg_database
+     WHERE pg_database.datname = '$dbname'
+            AND pg_database.oid = ext_vacuum_statistics.pg_stats_vacuum_database.dboid;"
+);
+ok($base_stats eq 't', 'check database-level vacuum stats from another database are available');
+};
+
+#------------------------------------------------------------------------------
+# Test 10: Cleanup checks: ensure functions return empty sets for OID = 0
+#------------------------------------------------------------------------------
+subtest 'Test 10: Cleanup checks: ensure functions return empty sets for OID = 0' => sub
+{
+my $dboid = $node->safe_psql(
+    $dbname,
+    "SELECT oid FROM pg_database WHERE datname = current_database();"
+);
+
+# Vacuum statistics for invalid relation OID return empty
+$base_stats = $node->safe_psql(
+    $dbname,
+    q{
+       SELECT COUNT(*)
+         FROM ext_vacuum_statistics.pg_stats_get_vacuum_tables((SELECT oid FROM pg_database WHERE datname = current_database()), 0);
+    }
+);
+is($base_stats, 0, 'vacuum stats per heap from invalid relation OID return empty as expected');
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    q{
+       SELECT COUNT(*)
+         FROM ext_vacuum_statistics.pg_stats_get_vacuum_indexes((SELECT oid FROM pg_database WHERE datname = current_database()), 0);
+    }
+);
+is($base_stats, 0, 'vacuum stats per index from invalid relation OID return empty as expected');
+
+$node->safe_psql($dbname, q{
+    DROP TABLE vestat CASCADE;
+    VACUUM;
+});
+
+# Check that we don't print vacuum statistics for deleted objects
+$base_stats = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT COUNT(*)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relid = 0;
+    }
+);
+is($base_stats, 0, 'ext_vacuum_statistics.pg_stats_vacuum_tables correctly returns no rows for OID = 0');
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT COUNT(*)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_indexes WHERE indexrelid = 0;
+    }
+);
+is($base_stats, 0, 'ext_vacuum_statistics.pg_stats_vacuum_indexes correctly returns no rows for OID = 0');
+
+my $reloid = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT oid FROM pg_class WHERE relname = 'pg_shdepend';
+    }
+);
+
+$node->safe_psql($dbname, "VACUUM pg_shdepend;");
+
+# Check if we can get vacuum statistics for cluster relations (shared catalogs)
+$base_stats = $node->safe_psql(
+    $dbname,
+    qq{
+        SELECT count(*) > 0
+        FROM ext_vacuum_statistics.pg_stats_get_vacuum_tables((SELECT oid FROM pg_database WHERE datname = current_database()), $reloid);
+    }
+);
+
+is($base_stats, 't', 'vacuum stats for common heap objects available');
+
+my $indoid = $node->safe_psql(
+    $dbname,
+    q{
+        SELECT oid FROM pg_class WHERE relname = 'pg_shdepend_reference_index';
+    }
+);
+
+$base_stats = $node->safe_psql(
+    $dbname,
+    qq{
+        SELECT count(*) > 0
+        FROM ext_vacuum_statistics.pg_stats_get_vacuum_indexes((SELECT oid FROM pg_database WHERE datname = current_database()), $indoid);
+    }
+);
+
+is($base_stats, 't', 'vacuum stats for common index objects available');
+
+$node->safe_psql('postgres',
+    "DROP DATABASE $dbname;
+     VACUUM;"
+);
+
+$base_stats = $node->safe_psql(
+    'postgres',
+    q{
+       SELECT count(*) = 0
+        FROM ext_vacuum_statistics.pg_stats_get_vacuum_database(0);
+    }
+);
+is($base_stats, 't', 'vacuum stats from database with invalid database OID return empty, as expected');
+};
+
+$node->stop;
+
+done_testing();
diff --git a/contrib/ext_vacuum_statistics/t/053_vacuum_extending_freeze_test.pl b/contrib/ext_vacuum_statistics/t/053_vacuum_extending_freeze_test.pl
new file mode 100644
index 00000000000..4f8f025c63e
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/t/053_vacuum_extending_freeze_test.pl
@@ -0,0 +1,285 @@
+# Copyright (c) 2025 PostgreSQL Global Development Group
+#
+# Test cumulative vacuum stats using ext_vacuum_statistics extension (TAP)
+#
+# In short, this test validates the correctness and stability of cumulative
+# vacuum statistics accounting around freezing, visibility, and revision
+# tracking across multiple VACUUMs and backend operations.
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+#------------------------------------------------------------------------------
+# Test cluster setup
+#------------------------------------------------------------------------------
+
+my $node = PostgreSQL::Test::Cluster->new('ext_stat_vacuum');
+$node->init;
+
+# Configure the server: preload extension and aggressive freezing behavior
+$node->append_conf('postgresql.conf', q{
+    shared_preload_libraries = 'ext_vacuum_statistics'
+    log_min_messages = notice
+    vacuum_freeze_min_age = 0
+    vacuum_freeze_table_age = 0
+    vacuum_multixact_freeze_min_age = 0
+    vacuum_multixact_freeze_table_age = 0
+    vacuum_max_eager_freeze_failure_rate = 1.0
+    vacuum_failsafe_age = 0
+    vacuum_multixact_failsafe_age = 0
+    track_functions = 'all'
+});
+
+$node->start();
+
+#------------------------------------------------------------------------------
+# Database creation and initialization
+#------------------------------------------------------------------------------
+
+$node->safe_psql('postgres', q{
+    CREATE DATABASE statistic_vacuum_database_regression;
+});
+
+# Main test database name
+my $dbname = 'statistic_vacuum_database_regression';
+
+# Create extension
+$node->safe_psql($dbname, q{
+    CREATE EXTENSION ext_vacuum_statistics;
+});
+
+#------------------------------------------------------------------------------
+# Timing parameters for polling loops
+#------------------------------------------------------------------------------
+
+my $timeout    = 30;     # overall wait timeout in seconds
+my $interval   = 0.015;  # poll interval in seconds (15 ms)
+my $start_time = time();
+my $updated    = 0;
+
+#------------------------------------------------------------------------------
+# wait_for_vacuum_stats
+#
+# Polls ext_vacuum_statistics.pg_stats_vacuum_tables until the named columns exceed the
+# provided baseline values or until timeout.
+#
+#   tab_all_frozen_pages_count  => 0   # baseline numeric
+#   tab_all_visible_pages_count => 0   # baseline numeric
+#   run_vacuum                  => 0   # if true, run vacuum before polling
+#
+# Returns: 1 if the condition is met before timeout, 0 otherwise.
+#------------------------------------------------------------------------------
+sub wait_for_vacuum_stats {
+    my (%args) = @_;
+
+    my $tab_all_frozen_pages_count  = $args{tab_all_frozen_pages_count} || 0;
+    my $tab_all_visible_pages_count = $args{tab_all_visible_pages_count} || 0;
+    my $run_vacuum                  = $args{run_vacuum} ? 1 : 0;
+    my $result_query;
+
+    my $start = time();
+    my $sql;
+
+    # Run VACUUM once if requested, before polling
+    if ($run_vacuum) {
+        $node->safe_psql($dbname, 'VACUUM (FREEZE, VERBOSE) vestat');
+    }
+
+    while ((time() - $start) < $timeout) {
+
+        if ($run_vacuum) {
+            $sql = "
+            SELECT (vm_new_visible_frozen_pages > $tab_all_frozen_pages_count)
+               FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+              WHERE relname = 'vestat'";
+        }
+        else {
+            $sql = "
+            SELECT (pg_stat_get_frozen_page_marks_cleared(c.oid) > $tab_all_frozen_pages_count AND
+                     pg_stat_get_visible_page_marks_cleared(c.oid) > $tab_all_visible_pages_count)
+               FROM pg_class c
+              WHERE relname = 'vestat'";
+        }
+
+        $result_query = $node->safe_psql($dbname, $sql);
+
+        return 1 if (defined $result_query && $result_query eq 't');
+
+        sleep($interval);
+    }
+
+    return 0;
+}
+
+#------------------------------------------------------------------------------
+# Variables to hold vacuum statistics snapshots for comparisons
+#------------------------------------------------------------------------------
+
+my $vm_new_visible_frozen_pages = 0;
+
+my $rev_all_frozen_pages = 0;
+my $rev_all_visible_pages = 0;
+
+my $vm_new_visible_frozen_pages_prev = 0;
+
+my $rev_all_frozen_pages_prev = 0;
+my $rev_all_visible_pages_prev = 0;
+
+my $res;
+
+#------------------------------------------------------------------------------
+# fetch_vacuum_stats
+#
+# Loads current values of the relevant vacuum counters for the test table
+# into the package-level variables above so tests can compare later.
+#------------------------------------------------------------------------------
+
+sub fetch_vacuum_stats {
+    $vm_new_visible_frozen_pages = $node->safe_psql(
+        $dbname,
+        "SELECT vt.vm_new_visible_frozen_pages
+           FROM ext_vacuum_statistics.pg_stats_vacuum_tables vt
+          WHERE vt.relname = 'vestat';"
+    );
+
+    $rev_all_frozen_pages = $node->safe_psql(
+        $dbname,
+        "SELECT pg_stat_get_frozen_page_marks_cleared(c.oid)
+           FROM pg_class c
+          WHERE c.relname = 'vestat';"
+    );
+
+    $rev_all_visible_pages = $node->safe_psql(
+        $dbname,
+        "SELECT pg_stat_get_visible_page_marks_cleared(c.oid)
+           FROM pg_class c
+          WHERE c.relname = 'vestat';"
+    );
+}
+
+#------------------------------------------------------------------------------
+# save_vacuum_stats
+#------------------------------------------------------------------------------
+sub save_vacuum_stats {
+    $vm_new_visible_frozen_pages_prev = $vm_new_visible_frozen_pages;
+    $rev_all_frozen_pages_prev = $rev_all_frozen_pages;
+    $rev_all_visible_pages_prev = $rev_all_visible_pages;
+}
+
+#------------------------------------------------------------------------------
+# print_vacuum_stats_on_error
+#------------------------------------------------------------------------------
+sub print_vacuum_stats_on_error {
+    diag(
+            "Statistics in the failed test\n" .
+            "Table statistics:\n" .
+            "  Before test:\n" .
+            "    vm_new_visible_frozen_pages = $vm_new_visible_frozen_pages_prev\n" .
+            "    rev_all_frozen_pages = $rev_all_frozen_pages_prev\n" .
+            "    rev_all_visible_pages = $rev_all_visible_pages_prev\n" .
+            "  After test:\n" .
+            "    vm_new_visible_frozen_pages = $vm_new_visible_frozen_pages\n" .
+            "    rev_all_frozen_pages = $rev_all_frozen_pages\n" .
+            "    rev_all_visible_pages = $rev_all_visible_pages\n"
+    );
+};
+
+#------------------------------------------------------------------------------
+# Test 1: Create test table, populate it and run an initial vacuum to force freezing
+#------------------------------------------------------------------------------
+
+subtest 'Test 1: Create test table, populate it and run an initial vacuum to force freezing' => sub
+{
+$node->safe_psql($dbname, q{
+    CREATE TABLE vestat (x int)
+        WITH (autovacuum_enabled = off, fillfactor = 10);
+    INSERT INTO vestat SELECT x FROM generate_series(1, 1000) AS g(x);
+    ANALYZE vestat;
+    VACUUM (FREEZE, VERBOSE) vestat;
+});
+
+$updated = wait_for_vacuum_stats(
+    tab_all_frozen_pages_count  => 0,
+    tab_all_visible_pages_count => 0,
+    run_vacuum                  => 1,
+);
+
+ok($updated,
+   'vacuum stats updated after vacuuming the table (vm_new_visible_frozen_pages advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics to update after $timeout seconds during vacuum";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages > $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages has increased');
+ok($rev_all_frozen_pages == $rev_all_frozen_pages_prev, 'table rev_all_frozen_pages stay the same');
+ok($rev_all_visible_pages == $rev_all_visible_pages_prev, 'table rev_all_visible_pages stay the same');
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 2: Trigger backend updates
+# Backend activity should reset per-page visibility/freeze marks and increment revision counters
+#------------------------------------------------------------------------------
+subtest 'Test 2: Trigger backend updates' => sub
+{
+save_vacuum_stats();
+
+$node->safe_psql($dbname, q{
+    UPDATE vestat SET x = x + 1001;
+});
+
+$updated = wait_for_vacuum_stats(
+    tab_all_frozen_pages_count  => 0,
+    tab_all_visible_pages_count => 0,
+    run_vacuum                  => 0,
+);
+
+ok($updated,
+   'vacuum stats updated after backend tuple updates (rev_all_frozen_pages and rev_all_visible_pages advanced)')
+  or diag "Timeout waiting for vacuum stats update after $timeout seconds";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages == $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages stay the same');
+ok($rev_all_frozen_pages > $rev_all_frozen_pages_prev, 'table rev_all_frozen_pages has increased');
+ok($rev_all_visible_pages > $rev_all_visible_pages_prev, 'table rev_all_visible_pages has increased');
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Test 3: Force another vacuum after backend modifications - vacuum should restore freeze/visibility
+#------------------------------------------------------------------------------
+subtest 'Test 3: Force another vacuum after backend modifications - vacuum should restore freeze/visibility' => sub
+{
+save_vacuum_stats();
+
+$node->safe_psql($dbname, q{ VACUUM vestat; });
+
+$updated = wait_for_vacuum_stats(
+    tab_all_frozen_pages_count  => $vm_new_visible_frozen_pages,
+    tab_all_visible_pages_count => 0,
+    run_vacuum                  => 1,
+);
+
+ok($updated,
+   'vacuum stats updated after vacuuming the all-updated table (vm_new_visible_frozen_pages advanced)')
+  or diag "Timeout waiting for ext_vacuum_statistics to update after $timeout seconds during vacuum";
+
+fetch_vacuum_stats();
+
+ok($vm_new_visible_frozen_pages > $vm_new_visible_frozen_pages_prev, 'table vm_new_visible_frozen_pages has increased');
+ok($rev_all_frozen_pages == $rev_all_frozen_pages_prev, 'table rev_all_frozen_pages stay the same');
+ok($rev_all_visible_pages == $rev_all_visible_pages_prev, 'table rev_all_visible_pages stay the same');
+} or print_vacuum_stats_on_error();
+
+#------------------------------------------------------------------------------
+# Cleanup
+#------------------------------------------------------------------------------
+
+$node->safe_psql('postgres', q{
+    DROP DATABASE statistic_vacuum_database_regression;
+});
+
+$node->stop;
+done_testing();
diff --git a/contrib/ext_vacuum_statistics/t/054_vacuum_extending_gucs_test.pl b/contrib/ext_vacuum_statistics/t/054_vacuum_extending_gucs_test.pl
new file mode 100644
index 00000000000..a195249842b
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/t/054_vacuum_extending_gucs_test.pl
@@ -0,0 +1,279 @@
+# Copyright (c) 2025 PostgreSQL Global Development Group
+#
+# Test GUC parameters for ext_vacuum_statistics extension:
+#   vacuum_statistics.enabled
+#   vacuum_statistics.object_types (all, databases, relations)
+#   vacuum_statistics.track_relations (all, system, user)
+#   vacuum_statistics.track_databases_from_list, add/remove_track_database
+#   add/remove_track_database, add/remove_track_relation, track_*_from_list
+
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+ 
+use Test::More;
+
+#------------------------------------------------------------------------------
+# Test cluster setup
+#------------------------------------------------------------------------------
+
+my $node = PostgreSQL::Test::Cluster->new('ext_stat_vacuum_gucs');
+$node->init;
+
+$node->append_conf('postgresql.conf', q{
+    shared_preload_libraries = 'ext_vacuum_statistics'
+    log_min_messages = notice
+});
+
+$node->start;
+
+#------------------------------------------------------------------------------
+# Database creation and initialization
+#------------------------------------------------------------------------------
+
+$node->safe_psql('postgres', q{
+    CREATE DATABASE statistic_vacuum_gucs;
+});
+
+my $dbname = 'statistic_vacuum_gucs';
+
+$node->safe_psql($dbname, q{
+    CREATE EXTENSION ext_vacuum_statistics;
+    CREATE TABLE guc_test (x int PRIMARY KEY)
+        WITH (autovacuum_enabled = off);
+    INSERT INTO guc_test SELECT x FROM generate_series(1, 100) AS g(x);
+    ANALYZE guc_test;
+});
+
+# Get OIDs for filtering tests
+my $dboid = $node->safe_psql($dbname, q{SELECT oid FROM pg_database WHERE datname = current_database()});
+my $reloid = $node->safe_psql($dbname, q{SELECT oid FROM pg_class WHERE relname = 'guc_test'});
+
+#------------------------------------------------------------------------------
+# Reset stats and run vacuum (all in one session so GUCs persist)
+#------------------------------------------------------------------------------
+
+sub reset_and_vacuum {
+    my ($db, $table, $opts) = @_;
+    $table ||= 'guc_test';
+    my $gucs = $opts && $opts->{gucs} ? $opts->{gucs} : [];
+    my $modify = $opts && $opts->{modify};
+    my $extra = $opts && $opts->{extra_vacuum} ? $opts->{extra_vacuum} : [];
+    $extra = [$extra] unless ref $extra eq 'ARRAY';
+    my $sql = join("\n", (map { "SET $_;" } @$gucs),
+        "SELECT ext_vacuum_statistics.vacuum_statistics_reset();",
+        $modify ? (
+            "TRUNCATE $table;",
+            "INSERT INTO $table SELECT x FROM generate_series(1, 100) AS g(x);",
+            "DELETE FROM $table;",
+        ) : (),
+        "VACUUM $table;",
+        (map { "VACUUM $_;" } @$extra),
+        # Make pending stats visible to subsequent sessions without sleeping.
+        "SELECT pg_stat_force_next_flush();");
+    $node->safe_psql($db, $sql);
+}
+
+#------------------------------------------------------------------------------
+# Test 1: vacuum_statistics.enabled
+#------------------------------------------------------------------------------
+subtest 'vacuum_statistics.enabled' => sub {
+    reset_and_vacuum($dbname);
+
+    # Default: enabled - should have stats
+    my $count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    ok($count > 0, 'stats collected when enabled');
+
+    # Disable, reset and vacuum in same session.  Assert not only that the
+    # row count is zero, but that the specific counters remain zero: a stray
+    # row with zero counters would otherwise pass a bare COUNT(*)=0 check.
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ['vacuum_statistics.enabled = off'] });
+
+    $count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    is($count, 0, 'no rows when disabled');
+
+    my $sums = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(total_blks_read), 0)
+             + COALESCE(SUM(total_blks_dirtied), 0)
+             + COALESCE(SUM(pages_scanned), 0)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($sums, '0', 'no counters accumulated when disabled');
+};
+
+#------------------------------------------------------------------------------
+# Test 2: vacuum_statistics.object_types (databases only, relations only)
+#------------------------------------------------------------------------------
+subtest 'vacuum_statistics.object_types' => sub {
+    # track only db stats, no relation stats
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => ["vacuum_statistics.object_types = 'databases'"],
+        modify => 1,
+    });
+    my $db_has_dbs = $node->safe_psql($dbname,
+        "SELECT COALESCE(SUM(db_blks_hit), 0) FROM ext_vacuum_statistics.pg_stats_vacuum_database WHERE dboid = $dboid");
+    my $rel_dbs = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    is($rel_dbs, 0, 'track=databases: no relation stats');
+    ok($db_has_dbs > 0, 'track=databases: database stats collected');
+
+    # track only relation stats, no db stats
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => ["vacuum_statistics.object_types = 'relations'"],
+        modify => 1,
+    });
+    my $db_has_rels = $node->safe_psql($dbname,
+        "SELECT COALESCE(SUM(db_blks_hit), 0) > 0 FROM ext_vacuum_statistics.pg_stats_vacuum_database WHERE dboid = $dboid");
+    my $rel_rels = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    ok($rel_rels > 0, 'track=relations: relation stats collected');
+    is($db_has_rels, 'f', 'track=relations: no database stats');
+};
+
+#------------------------------------------------------------------------------
+# Test 3: vacuum_statistics.track_relations (system, user)
+#------------------------------------------------------------------------------
+subtest 'vacuum_statistics.track_relations' => sub {
+    # track_relations - only user tables
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => [
+            "vacuum_statistics.object_types = 'relations'",
+            "vacuum_statistics.track_relations = 'user'",
+        ],
+        extra_vacuum => ['pg_class'],
+    });
+
+    my $user_rel = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    my $sys_rel = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'pg_class'");
+    ok($user_rel > 0, 'track_relations=user: user table stats collected');
+    is($sys_rel, 0, 'track_relations=user: system table stats not collected');
+
+    # track_relations - only system tables
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => [
+            "vacuum_statistics.object_types = 'relations'",
+            "vacuum_statistics.track_relations = 'system'",
+        ],
+        extra_vacuum => ['pg_class'],
+    });
+
+    $user_rel = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    $sys_rel = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'pg_class'");
+    is($user_rel, 0, 'track_relations=system: user table stats not collected');
+    ok($sys_rel > 0, 'track_relations=system: system table stats collected');
+};
+
+#------------------------------------------------------------------------------
+# Test 4: track_databases (via add/remove_track_database)
+#------------------------------------------------------------------------------
+subtest 'track_databases (add/remove)' => sub {
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.remove_track_database($dboid)");
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.add_track_database($dboid)");
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ["vacuum_statistics.track_databases_from_list = on"], modify => 1 });
+
+    my $rel_count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    ok($rel_count > 0, 'db in list: stats collected');
+
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.remove_track_database($dboid)");
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ["vacuum_statistics.track_databases_from_list = on"], modify => 1 });
+
+    $rel_count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    is($rel_count, 0, 'db removed from list: no stats');
+};
+
+#------------------------------------------------------------------------------
+# Test 5: track_relations (via add/remove_track_relation)
+#------------------------------------------------------------------------------
+subtest 'track_relations (add/remove)' => sub {
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.remove_track_relation($dboid, $reloid)");
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.add_track_relation($dboid, $reloid)");
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ["vacuum_statistics.track_relations_from_list = on"], modify => 1 });
+
+    my $rel_count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    ok($rel_count > 0, 'table in list: stats collected');
+
+    $node->safe_psql($dbname, "SELECT ext_vacuum_statistics.remove_track_relation($dboid, $reloid)");
+    reset_and_vacuum($dbname, 'guc_test', { gucs => ["vacuum_statistics.track_relations_from_list = on"], modify => 1 });
+
+    $rel_count = $node->safe_psql($dbname,
+        "SELECT COUNT(*) FROM ext_vacuum_statistics.pg_stats_vacuum_tables WHERE relname = 'guc_test'");
+    is($rel_count, 0, 'table removed from list: no stats');
+};
+
+#------------------------------------------------------------------------------
+# Test 6: vacuum_statistics.collect - per-category gating
+#
+# With collect='wal' only wal_* counters must advance; buffer, timing, and
+# general categories must stay at zero.  With collect='buffers' the inverse
+# holds.  Unknown tokens must be rejected by the check-hook.
+#------------------------------------------------------------------------------
+subtest 'vacuum_statistics.collect' => sub {
+    # wal-only: WAL counters should accumulate, buffers/timing/general should not.
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => ["vacuum_statistics.collect = 'wal'"],
+        modify => 1,
+    });
+
+    my $wal = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(wal_records), 0) > 0
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($wal, 't', "collect='wal': wal_records accumulated");
+
+    my $other = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(total_blks_read), 0)
+             + COALESCE(SUM(total_blks_hit), 0)
+             + COALESCE(SUM(total_time), 0)
+             + COALESCE(SUM(tuples_deleted), 0)
+             + COALESCE(SUM(pages_scanned), 0)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($other, '0',
+        "collect='wal': buffer/timing/general counters not accumulated");
+
+    # buffers-only: buffer counters should advance, WAL should not.
+    reset_and_vacuum($dbname, 'guc_test', {
+        gucs => ["vacuum_statistics.collect = 'buffers'"],
+        modify => 1,
+    });
+
+    my $buf = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(total_blks_read), 0)
+             + COALESCE(SUM(total_blks_hit), 0) > 0
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($buf, 't', "collect='buffers': buffer counters accumulated");
+
+    my $wal_off = $node->safe_psql($dbname, q{
+        SELECT COALESCE(SUM(wal_records), 0)
+          FROM ext_vacuum_statistics.pg_stats_vacuum_tables
+         WHERE relname = 'guc_test'
+    });
+    is($wal_off, '0',
+        "collect='buffers': WAL counters not accumulated");
+
+    # Unknown category must be rejected by the check-hook.
+    my ($ret, $stdout, $stderr) = $node->psql($dbname,
+        "SET vacuum_statistics.collect = 'nope'");
+    isnt($ret, 0, "collect='nope': rejected by check-hook");
+    like($stderr, qr/Unrecognized category "nope"/,
+        "collect='nope': errdetail names the offending token");
+};
+
+$node->stop;
+
+done_testing();
diff --git a/contrib/ext_vacuum_statistics/vacuum_statistics.c b/contrib/ext_vacuum_statistics/vacuum_statistics.c
new file mode 100644
index 00000000000..144b9bcb814
--- /dev/null
+++ b/contrib/ext_vacuum_statistics/vacuum_statistics.c
@@ -0,0 +1,1387 @@
+/*
+ * ext_vacuum_statistics - Extended vacuum statistics for PostgreSQL
+ *
+ * This module collects detailed vacuum statistics (I/O, WAL, timing, etc.)
+ * at relation and database level by hooking into the vacuum reporting path.
+ * Statistics are stored via pgstat custom statistics. Management of statistics
+ * storage and output functions are implemented in this module.
+ */
+#include "postgres.h"
+
+#include "access/transam.h"
+#include "catalog/catalog.h"
+#include "catalog/objectaccess.h"
+#include "catalog/pg_authid.h"
+#include "catalog/pg_class.h"
+#include "catalog/pg_database.h"
+#include "fmgr.h"
+#include "funcapi.h"
+#include "miscadmin.h"
+#include "pgstat.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "storage/lwlock.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/fmgrprotos.h"
+#include "utils/guc.h"
+#include "utils/hsearch.h"
+#include "utils/lsyscache.h"
+#include "utils/pgstat_kind.h"
+#include "utils/pgstat_internal.h"
+#include "utils/tuplestore.h"
+
+#ifdef PG_MODULE_MAGIC
+PG_MODULE_MAGIC;
+#endif
+
+/* Two kinds: relations (tables/indexes) and database aggregates */
+#define PGSTAT_KIND_EXTVAC_RELATION	24
+#define PGSTAT_KIND_EXTVAC_DB		25
+
+#define SJ_NODENAME		"vacuum_statistics"
+#define EVS_TRACK_FILENAME	"pg_stat/ext_vacuum_statistics_track.oid"
+
+/* Bit flags for evs_track (object_types): 'all', 'databases', 'relations' */
+#define EVS_TRACK_RELATIONS		0x01
+#define EVS_TRACK_DATABASES		0x02
+
+/* Bit flags for evs_track_relations: 'all', 'system', 'user' */
+#define EVS_FILTER_SYSTEM		0x01
+#define EVS_FILTER_USER			0x02
+
+/*
+ * Bit flags for evs_collect_mask. Each category groups counters that can be
+ * accumulated (or skipped) together, letting users reduce overhead at run
+ * time by turning off categories they don't need.
+ */
+#define EVS_COLLECT_BUFFERS		0x1 /* blks_*, blk_*_time */
+#define EVS_COLLECT_WAL			0x2 /* wal_records, wal_fpi, wal_bytes */
+#define EVS_COLLECT_GENERAL		0x4 /* tuples_deleted, pages_*, vm_*,
+									 * wraparound_failsafe_count,
+									 * interrupts_count */
+#define EVS_COLLECT_TIMING		0x8 /* delay_time, total_time */
+#define EVS_COLLECT_ALL			(EVS_COLLECT_BUFFERS | EVS_COLLECT_WAL | \
+								 EVS_COLLECT_GENERAL | EVS_COLLECT_TIMING)
+
+/*  GUCs  */
+static bool evs_enabled = true;
+static char *evs_track = "all"; /* 'all', 'databases', 'relations' */
+static char *evs_track_relations = "all";	/* 'all', 'system', 'user' */
+static int	evs_track_bits = EVS_TRACK_RELATIONS | EVS_TRACK_DATABASES;
+static int	evs_track_relations_bits = EVS_FILTER_SYSTEM | EVS_FILTER_USER;
+static bool evs_track_databases_from_list = false;	/* if true, track only
+													 * databases in list */
+static bool evs_track_relations_from_list = false;	/* if true, track only
+													 * relations in list */
+static char *evs_collect = "all";	/* categories to collect */
+static int	evs_collect_mask = EVS_COLLECT_ALL;
+
+/*  Hook  */
+static set_report_vacuum_hook_type prev_report_vacuum_hook = NULL;
+static object_access_hook_type prev_object_access_hook = NULL;
+static shmem_request_hook_type prev_shmem_request_hook = NULL;
+
+/*  Forward declarations  */
+static void pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
+										  PgStat_VacuumRelationCounts * params);
+static bool evs_oid_in_list(HTAB *hash, Oid oid);
+static void evs_track_hash_ensure_init(void);
+static void evs_track_save_file(void);
+static void evs_track_load_file(void);
+static void evs_drop_access_hook(ObjectAccessType access, Oid classId,
+								 Oid objectId, int subId, void *arg);
+static void evs_shmem_request(void);
+
+/* Hash tables for track_databases and track_relations_list (backend-local) */
+static HTAB *evs_track_databases_hash = NULL;
+static HTAB *evs_track_relations_hash = NULL;
+static bool evs_track_hash_initialized = false;
+
+/*
+ * Named LWLock tranche protecting the on-disk track file and serializing
+ * backend-local reloads/saves across concurrent backends.
+ */
+#define EVS_TRACK_TRANCHE_NAME "ext_vacuum_statistics_track"
+static LWLock *evs_track_lock = NULL;
+
+static inline LWLock *
+evs_get_track_lock(void)
+{
+	if (evs_track_lock == NULL)
+		evs_track_lock = &GetNamedLWLockTranche(EVS_TRACK_TRANCHE_NAME)->lock;
+	return evs_track_lock;
+}
+
+/*
+ * objid encoding for relations: (relid << 2) | (type & 3)
+ */
+#define EXTVAC_OBJID(relid, type) (((uint64) (relid)) << 2 | ((type) & 3))
+
+/* Key for relation tracking: (dboid, reloid).
+ * InvalidOid for dboid means it is a cluster object.
+ */
+typedef struct
+{
+	Oid			dboid;
+	Oid			reloid;
+}			EvsTrackRelKey;
+
+/* Shared memory entry for vacuum stats; one per relation or database. */
+typedef struct PgStatShared_ExtVacEntry
+{
+	PgStatShared_Common header;
+	PgStat_VacuumRelationCounts stats;
+}			PgStatShared_ExtVacEntry;
+
+/* PgStat kind for per-relation vacuum statistics (tables/indexes) */
+static const PgStat_KindInfo extvac_relation_kind_info = {
+	.name = "ext_vacuum_statistics_relation",
+	.fixed_amount = false,
+	.accessed_across_databases = true,
+	.write_to_file = true,
+	.track_entry_count = true,
+	.shared_size = sizeof(PgStatShared_ExtVacEntry),
+	.shared_data_off = offsetof(PgStatShared_ExtVacEntry, stats),
+	.shared_data_len = sizeof(PgStat_VacuumRelationCounts),
+	.pending_size = 0,
+	.flush_pending_cb = NULL,
+};
+
+/* PgStat kind for per-database aggregated vacuum statistics */
+static const PgStat_KindInfo extvac_db_kind_info = {
+	.name = "ext_vacuum_statistics_db",
+	.fixed_amount = false,
+	.accessed_across_databases = true,
+	.write_to_file = true,
+	.track_entry_count = true,
+	.shared_size = sizeof(PgStatShared_ExtVacEntry),
+	.shared_data_off = offsetof(PgStatShared_ExtVacEntry, stats),
+	.shared_data_len = sizeof(PgStat_VacuumRelationCounts),
+	.pending_size = 0,
+	.flush_pending_cb = NULL,
+};
+
+/*
+ * Accumulate a single counter only if its category is enabled in
+ * evs_collect_mask. Parentheses around every argument: the macro is invoked
+ * from expression contexts and with expressions as the destination pointer.
+ */
+#define ACCUM_IF(dst, src, field, cat) \
+	do { \
+		if ((evs_collect_mask) & (cat)) \
+			((dst))->field += ((src))->field; \
+	} while (0)
+
+static inline void
+pgstat_accumulate_common(PgStat_CommonCounts * dst, const PgStat_CommonCounts * src)
+{
+	ACCUM_IF(dst, src, total_blks_read, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, total_blks_hit, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, total_blks_dirtied, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, total_blks_written, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, blks_fetched, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, blks_hit, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, blk_read_time, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, blk_write_time, EVS_COLLECT_BUFFERS);
+	ACCUM_IF(dst, src, delay_time, EVS_COLLECT_TIMING);
+	ACCUM_IF(dst, src, total_time, EVS_COLLECT_TIMING);
+	ACCUM_IF(dst, src, wal_records, EVS_COLLECT_WAL);
+	ACCUM_IF(dst, src, wal_fpi, EVS_COLLECT_WAL);
+	ACCUM_IF(dst, src, wal_bytes, EVS_COLLECT_WAL);
+	ACCUM_IF(dst, src, wraparound_failsafe_count, EVS_COLLECT_GENERAL);
+	ACCUM_IF(dst, src, interrupts_count, EVS_COLLECT_GENERAL);
+	ACCUM_IF(dst, src, tuples_deleted, EVS_COLLECT_GENERAL);
+}
+
+static inline void
+pgstat_accumulate_extvac_stats(PgStat_VacuumRelationCounts * dst,
+							   const PgStat_VacuumRelationCounts * src)
+{
+	if (dst->type == PGSTAT_EXTVAC_INVALID)
+		dst->type = src->type;
+
+	Assert(src->type != PGSTAT_EXTVAC_INVALID && src->type != PGSTAT_EXTVAC_DB);
+	Assert(src->type == dst->type);
+
+	pgstat_accumulate_common(&dst->common, &src->common);
+
+	if (dst->type == PGSTAT_EXTVAC_TABLE &&
+		(evs_collect_mask & EVS_COLLECT_GENERAL) != 0)
+	{
+		dst->table.pages_scanned += src->table.pages_scanned;
+		dst->table.pages_removed += src->table.pages_removed;
+		dst->table.tuples_frozen += src->table.tuples_frozen;
+		dst->table.recently_dead_tuples += src->table.recently_dead_tuples;
+		dst->table.vm_new_frozen_pages += src->table.vm_new_frozen_pages;
+		dst->table.vm_new_visible_pages += src->table.vm_new_visible_pages;
+		dst->table.vm_new_visible_frozen_pages += src->table.vm_new_visible_frozen_pages;
+		dst->table.missed_dead_pages += src->table.missed_dead_pages;
+		dst->table.missed_dead_tuples += src->table.missed_dead_tuples;
+		dst->table.index_vacuum_count += src->table.index_vacuum_count;
+	}
+	else if (dst->type == PGSTAT_EXTVAC_INDEX &&
+			 (evs_collect_mask & EVS_COLLECT_GENERAL) != 0)
+	{
+		dst->index.pages_deleted += src->index.pages_deleted;
+	}
+}
+
+/*
+ * GUC check hooks: validate the string and compute the bitmask into *extra.
+ * Rejecting unknown values here prevents silent fall-through to "all".
+ */
+static bool
+evs_track_check_hook(char **newval, void **extra, GucSource source)
+{
+	int		   *bits;
+
+	if (*newval == NULL)
+		return false;
+
+	bits = (int *) guc_malloc(LOG, sizeof(int));
+	if (!bits)
+		return false;
+
+	if (strcmp(*newval, "all") == 0)
+		*bits = EVS_TRACK_RELATIONS | EVS_TRACK_DATABASES;
+	else if (strcmp(*newval, "databases") == 0)
+		*bits = EVS_TRACK_DATABASES;
+	else if (strcmp(*newval, "relations") == 0)
+		*bits = EVS_TRACK_RELATIONS;
+	else
+	{
+		guc_free(bits);
+		GUC_check_errdetail("Allowed values are \"all\", \"databases\", \"relations\".");
+		return false;
+	}
+	*extra = bits;
+	return true;
+}
+
+static void
+evs_track_assign_hook(const char *newval, void *extra)
+{
+	evs_track_bits = *((int *) extra);
+}
+
+static bool
+evs_track_relations_check_hook(char **newval, void **extra, GucSource source)
+{
+	int		   *bits;
+
+	if (*newval == NULL)
+		return false;
+
+	bits = (int *) guc_malloc(LOG, sizeof(int));
+	if (!bits)
+		return false;
+
+	if (strcmp(*newval, "all") == 0)
+		*bits = EVS_FILTER_SYSTEM | EVS_FILTER_USER;
+	else if (strcmp(*newval, "system") == 0)
+		*bits = EVS_FILTER_SYSTEM;
+	else if (strcmp(*newval, "user") == 0)
+		*bits = EVS_FILTER_USER;
+	else
+	{
+		guc_free(bits);
+		GUC_check_errdetail("Allowed values are \"all\", \"system\", \"user\".");
+		return false;
+	}
+	*extra = bits;
+	return true;
+}
+
+static void
+evs_track_relations_assign_hook(const char *newval, void *extra)
+{
+	evs_track_relations_bits = *((int *) extra);
+}
+
+/*
+ * Check hook for vacuum_statistics.collect.
+ *
+ * Accepts a comma- or whitespace-separated list of category names
+ * (buffers, wal, general, timing) or the shorthand "all".  Computes the
+ * matching bitmask once and stashes it in *extra; the assign hook just
+ * copies it into evs_collect_mask.  Unknown tokens are rejected so the
+ * setting cannot silently collapse to the "all" default.
+ */
+static bool
+evs_collect_check_hook(char **newval, void **extra, GucSource source)
+{
+	int		   *mask;
+	char	   *copy;
+	char	   *p;
+	char	   *tok;
+	int			accum = 0;
+	bool		saw_all = false;
+
+	if (*newval == NULL)
+		return false;
+
+	mask = (int *) guc_malloc(LOG, sizeof(int));
+	if (!mask)
+		return false;
+
+	/* Empty string means "all", matching the default behavior. */
+	if ((*newval)[0] == '\0')
+	{
+		*mask = EVS_COLLECT_ALL;
+		*extra = mask;
+		return true;
+	}
+
+	copy = pstrdup(*newval);
+	for (p = copy; (tok = strtok(p, " \t,")) != NULL; p = NULL)
+	{
+		if (pg_strcasecmp(tok, "all") == 0)
+			saw_all = true;
+		else if (pg_strcasecmp(tok, "buffers") == 0)
+			accum |= EVS_COLLECT_BUFFERS;
+		else if (pg_strcasecmp(tok, "wal") == 0)
+			accum |= EVS_COLLECT_WAL;
+		else if (pg_strcasecmp(tok, "general") == 0)
+			accum |= EVS_COLLECT_GENERAL;
+		else if (pg_strcasecmp(tok, "timing") == 0)
+			accum |= EVS_COLLECT_TIMING;
+		else
+		{
+			/*
+			 * GUC_check_errdetail formats the message immediately, but tok
+			 * points into copy; emit the detail first, then free the
+			 * scratch buffer so the formatted string is already stashed in
+			 * GUC_check_errdetail_string.
+			 */
+			GUC_check_errdetail("Unrecognized category \"%s\" in vacuum_statistics.collect; "
+								"allowed values are \"all\", \"buffers\", \"wal\", \"general\", \"timing\".",
+								tok);
+			pfree(copy);
+			guc_free(mask);
+			return false;
+		}
+	}
+	pfree(copy);
+
+	*mask = saw_all ? EVS_COLLECT_ALL : accum;
+	if (*mask == 0)
+		*mask = EVS_COLLECT_ALL;
+	*extra = mask;
+	return true;
+}
+
+static void
+evs_collect_assign_hook(const char *newval, void *extra)
+{
+	evs_collect_mask = *((int *) extra);
+}
+
+void
+_PG_init(void)
+{
+	if (!process_shared_preload_libraries_in_progress)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics module could be loaded only on startup."),
+				 errdetail("Add 'ext_vacuum_statistics' into the shared_preload_libraries list.")));
+
+	DefineCustomBoolVariable("vacuum_statistics.enabled",
+							 "Enable extended vacuum statistics collection.",
+							 NULL, &evs_enabled, true,
+							 PGC_SUSET, 0, NULL, NULL, NULL);
+
+	DefineCustomStringVariable("vacuum_statistics.object_types",
+							   "Object types for statistics: 'all', 'databases', 'relations'.",
+							   NULL, &evs_track, "all",
+							   PGC_SUSET, 0,
+							   evs_track_check_hook,
+							   evs_track_assign_hook, NULL);
+
+	DefineCustomStringVariable("vacuum_statistics.track_relations",
+							   "When tracking relations: 'all', 'system', 'user'.",
+							   NULL, &evs_track_relations, "all",
+							   PGC_SUSET, 0,
+							   evs_track_relations_check_hook,
+							   evs_track_relations_assign_hook, NULL);
+
+	DefineCustomBoolVariable("vacuum_statistics.track_databases_from_list",
+							 "If true, track only databases added via add_track_database.",
+							 NULL, &evs_track_databases_from_list, false,
+							 PGC_SUSET, 0, NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("vacuum_statistics.track_relations_from_list",
+							 "If true, track only relations added via add_track_relation.",
+							 NULL, &evs_track_relations_from_list, false,
+							 PGC_SUSET, 0, NULL, NULL, NULL);
+
+	DefineCustomStringVariable("vacuum_statistics.collect",
+							   "Statistics categories to collect.",
+							   "Comma- or whitespace-separated list of: "
+							   "\"buffers\", \"wal\", \"general\", \"timing\"; "
+							   "or \"all\" for every category (default).",
+							   &evs_collect, "all",
+							   PGC_SUSET, 0,
+							   evs_collect_check_hook,
+							   evs_collect_assign_hook, NULL);
+
+	MarkGUCPrefixReserved(SJ_NODENAME);
+
+	pgstat_register_kind(PGSTAT_KIND_EXTVAC_RELATION, &extvac_relation_kind_info);
+	pgstat_register_kind(PGSTAT_KIND_EXTVAC_DB, &extvac_db_kind_info);
+
+	prev_shmem_request_hook = shmem_request_hook;
+	shmem_request_hook = evs_shmem_request;
+
+	prev_report_vacuum_hook = set_report_vacuum_hook;
+	set_report_vacuum_hook = pgstat_report_vacuum_extstats;
+
+	prev_object_access_hook = object_access_hook;
+	object_access_hook = evs_drop_access_hook;
+}
+
+static void
+evs_shmem_request(void)
+{
+	if (prev_shmem_request_hook)
+		prev_shmem_request_hook();
+
+	RequestNamedLWLockTranche(EVS_TRACK_TRANCHE_NAME, 1);
+}
+
+/*
+ * Object access hook: remove dropped objects from track lists.
+ */
+static void
+evs_drop_access_hook(ObjectAccessType access, Oid classId,
+					 Oid objectId, int subId, void *arg)
+{
+	if (prev_object_access_hook)
+		(*prev_object_access_hook) (access, classId, objectId, subId, arg);
+
+	if (access == OAT_DROP)
+	{
+		if (classId == RelationRelationId && subId == 0)
+		{
+			char		relkind = get_rel_relkind(objectId);
+			EvsTrackRelKey key;
+			bool		found;
+
+			if (relkind == RELKIND_RELATION || relkind == RELKIND_INDEX)
+			{
+				LWLock	   *lock = evs_get_track_lock();
+
+				LWLockAcquire(lock, LW_EXCLUSIVE);
+				evs_track_hash_ensure_init();
+				key.dboid = MyDatabaseId;
+				key.reloid = objectId;
+				hash_search(evs_track_relations_hash, &key, HASH_REMOVE, &found);
+				key.dboid = InvalidOid;
+				hash_search(evs_track_relations_hash, &key, HASH_REMOVE, &found);
+				evs_track_save_file();
+				LWLockRelease(lock);
+			}
+		}
+
+		if (classId == DatabaseRelationId && objectId != InvalidOid)
+		{
+			LWLock	   *lock = evs_get_track_lock();
+			bool		found;
+
+			LWLockAcquire(lock, LW_EXCLUSIVE);
+			evs_track_hash_ensure_init();
+			hash_search(evs_track_databases_hash, &objectId, HASH_REMOVE, &found);
+			evs_track_save_file();
+			LWLockRelease(lock);
+		}
+	}
+}
+
+/*
+ * Storage of track lists in a separate file.
+ *
+ * Stores the lists of database OIDs and (dboid, reloid) pairs used for
+ * selective tracking when track_databases_from_list or track_relations_from_list
+ * is enabled.
+ * Data stores in pg_stat/ext_vacuum_statistics_track.oid
+ */
+/*
+ * Initialize the backend-local tracking hashes and load their contents
+ * from the on-disk file.
+ *
+ * The hashes are per-backend, so no lock is needed to protect them from
+ * other processes; however, another backend may be concurrently rewriting
+ * the track file, so we take a shared lock for the file read.
+ */
+static void
+evs_track_hash_ensure_init(void)
+{
+	HASHCTL		ctl;
+	LWLock	   *lock;
+	bool		need_load;
+
+	if (evs_track_hash_initialized)
+		return;
+
+	lock = evs_get_track_lock();
+
+	if (evs_track_databases_hash == NULL)
+	{
+		memset(&ctl, 0, sizeof(ctl));
+		ctl.keysize = sizeof(Oid);
+		ctl.entrysize = sizeof(Oid);
+		ctl.hcxt = TopMemoryContext;
+		evs_track_databases_hash =
+			hash_create("ext_vacuum_statistics track databases",
+						64, &ctl, HASH_ELEM | HASH_BLOBS);
+	}
+
+	if (evs_track_relations_hash == NULL)
+	{
+		memset(&ctl, 0, sizeof(ctl));
+		ctl.keysize = sizeof(EvsTrackRelKey);
+		ctl.entrysize = sizeof(EvsTrackRelKey);
+		ctl.hcxt = TopMemoryContext;
+		evs_track_relations_hash =
+			hash_create("ext_vacuum_statistics track relations",
+						64, &ctl, HASH_ELEM | HASH_BLOBS);
+	}
+
+	need_load = !LWLockHeldByMe(lock);
+	if (need_load)
+		LWLockAcquire(lock, LW_SHARED);
+	PG_TRY();
+	{
+		evs_track_load_file();
+		evs_track_hash_initialized = true;
+	}
+	PG_FINALLY();
+	{
+		if (need_load)
+			LWLockRelease(lock);
+	}
+	PG_END_TRY();
+}
+
+/*
+ * Load track lists from disk into the backend-local hashes.
+ *
+ * Caller must hold evs_track_lock at least in shared mode, since the file
+ * may be concurrently rewritten by another backend.
+ */
+static void
+evs_track_load_file(void)
+{
+	char		path[MAXPGPATH];
+	FILE	   *fp;
+	char		buf[MAXPGPATH];
+	bool		in_relations = false;
+	Oid			oid;
+	EvsTrackRelKey key;
+	bool		found;
+
+	if (!DataDir || DataDir[0] == '\0' ||
+		!evs_track_databases_hash || !evs_track_relations_hash)
+		return;
+
+	snprintf(path, sizeof(path), "%s/%s", DataDir, EVS_TRACK_FILENAME);
+	fp = AllocateFile(path, "r");
+	if (!fp)
+	{
+		if (errno != ENOENT)
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not open track file \"%s\": %m", path)));
+		return;
+	}
+
+	PG_TRY();
+	{
+		while (fgets(buf, sizeof(buf), fp))
+		{
+			size_t		len = strlen(buf);
+
+			/* Reject unterminated lines (longer than buffer) as corruption. */
+			if (len > 0 && buf[len - 1] != '\n' && !feof(fp))
+				ereport(ERROR,
+						(errcode(ERRCODE_DATA_CORRUPTED),
+						 errmsg("line too long in track file \"%s\"", path)));
+
+			if (strncmp(buf, "[databases]", 11) == 0)
+			{
+				in_relations = false;
+				continue;
+			}
+			if (strncmp(buf, "[relations]", 11) == 0)
+			{
+				in_relations = true;
+				continue;
+			}
+			if (in_relations)
+			{
+				if (sscanf(buf, "%u %u", &key.dboid, &key.reloid) == 2)
+					hash_search(evs_track_relations_hash, &key, HASH_ENTER, &found);
+				else if (sscanf(buf, "%u", &oid) == 1)
+				{
+					key.dboid = InvalidOid;
+					key.reloid = oid;
+					hash_search(evs_track_relations_hash, &key, HASH_ENTER, &found);
+				}
+			}
+			else if (sscanf(buf, "%u", &oid) == 1)
+				hash_search(evs_track_databases_hash, &oid, HASH_ENTER, &found);
+		}
+
+		if (ferror(fp))
+			ereport(ERROR,
+					(errcode_for_file_access(),
+					 errmsg("could not read track file \"%s\": %m", path)));
+	}
+	PG_FINALLY();
+	{
+		FreeFile(fp);
+	}
+	PG_END_TRY();
+}
+
+/*
+ * Atomically rewrite the track file. Caller must hold evs_track_lock
+ * in exclusive mode.
+ */
+static void
+evs_track_save_file(void)
+{
+	char		path[MAXPGPATH];
+	char		tmppath[MAXPGPATH];
+	FILE	   *fp;
+	HASH_SEQ_STATUS status;
+	Oid		   *entry;
+	EvsTrackRelKey *rel_entry;
+	bool		failed = false;
+
+	if (!DataDir || DataDir[0] == '\0' ||
+		!evs_track_databases_hash || !evs_track_relations_hash)
+		return;
+
+	snprintf(path, sizeof(path), "%s/%s", DataDir, EVS_TRACK_FILENAME);
+	snprintf(tmppath, sizeof(tmppath), "%s.tmp", path);
+
+	fp = AllocateFile(tmppath, PG_BINARY_W);
+	if (!fp)
+	{
+		ereport(LOG,
+				(errcode_for_file_access(),
+				 errmsg("could not create track file \"%s\": %m", tmppath)));
+		return;
+	}
+
+	PG_TRY();
+	{
+		if (fputs("[databases]\n", fp) == EOF)
+			failed = true;
+
+		if (!failed)
+		{
+			hash_seq_init(&status, evs_track_databases_hash);
+			while ((entry = (Oid *) hash_seq_search(&status)) != NULL)
+			{
+				if (fprintf(fp, "%u\n", *entry) < 0)
+				{
+					hash_seq_term(&status);
+					failed = true;
+					break;
+				}
+			}
+		}
+
+		if (!failed && fputs("[relations]\n", fp) == EOF)
+			failed = true;
+
+		if (!failed)
+		{
+			hash_seq_init(&status, evs_track_relations_hash);
+			while ((rel_entry = (EvsTrackRelKey *) hash_seq_search(&status)) != NULL)
+			{
+				int			rc;
+
+				if (OidIsValid(rel_entry->dboid))
+					rc = fprintf(fp, "%u %u\n", rel_entry->dboid, rel_entry->reloid);
+				else
+					rc = fprintf(fp, "0 %u\n", rel_entry->reloid);
+				if (rc < 0)
+				{
+					hash_seq_term(&status);
+					failed = true;
+					break;
+				}
+			}
+		}
+
+		if (!failed && fflush(fp) != 0)
+			failed = true;
+
+		if (!failed)
+		{
+			int			fd = fileno(fp);
+
+			if (fd >= 0 && pg_fsync(fd) != 0)
+				ereport(LOG,
+						(errcode_for_file_access(),
+						 errmsg("could not fsync track file \"%s\": %m",
+								tmppath)));
+		}
+	}
+	PG_CATCH();
+	{
+		FreeFile(fp);
+		(void) unlink(tmppath);
+		PG_RE_THROW();
+	}
+	PG_END_TRY();
+
+	if (FreeFile(fp) != 0)
+	{
+		ereport(LOG,
+				(errcode_for_file_access(),
+				 errmsg("could not close track file \"%s\": %m", tmppath)));
+		failed = true;
+	}
+
+	if (failed)
+	{
+		ereport(LOG,
+				(errcode_for_file_access(),
+				 errmsg("could not write track file \"%s\": %m", tmppath)));
+		if (unlink(tmppath) != 0 && errno != ENOENT)
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not unlink \"%s\": %m", tmppath)));
+		return;
+	}
+
+	if (durable_rename(tmppath, path, LOG) != 0)
+	{
+		if (unlink(tmppath) != 0 && errno != ENOENT)
+			ereport(LOG,
+					(errcode_for_file_access(),
+					 errmsg("could not unlink \"%s\": %m", tmppath)));
+	}
+}
+
+/*
+ * Check if OID is in the given hash
+ */
+static bool
+evs_oid_in_list(HTAB *hash, Oid oid)
+{
+	if (!hash)
+		return false;
+	if (hash_get_num_entries(hash) == 0)
+		return false;
+	return hash_search(hash, &oid, HASH_FIND, NULL) != NULL;
+}
+
+/*
+ * Check if (dboid, relid) is in track_relations list.
+ */
+static bool
+evs_rel_in_list(Oid dboid, Oid relid)
+{
+	EvsTrackRelKey key;
+
+	if (!evs_track_relations_hash)
+		return false;
+	if (hash_get_num_entries(evs_track_relations_hash) == 0)
+		return false;
+	key.dboid = dboid;
+	key.reloid = relid;
+	if (hash_search(evs_track_relations_hash, &key, HASH_FIND, NULL) != NULL)
+		return true;
+	key.dboid = InvalidOid;
+	return hash_search(evs_track_relations_hash, &key, HASH_FIND, NULL) != NULL;
+}
+
+/*
+ * Decide whether to track statistics for relations.
+ * Relation is tracked if it is in the track list or a special filter is enabled.
+ */
+static bool
+evs_should_track_relation_statistics(Oid dboid, Oid relid)
+{
+	evs_track_hash_ensure_init();
+
+	if (evs_track_databases_from_list &&
+		!evs_oid_in_list(evs_track_databases_hash, dboid))
+		return false;
+	if (evs_track_relations_from_list &&
+		!(evs_rel_in_list(dboid, relid) || evs_rel_in_list(InvalidOid, relid)))
+		return false;
+
+	if ((evs_track_bits & EVS_TRACK_RELATIONS) == 0)
+		return false;			/* database-only mode */
+	if (evs_track_relations_bits == EVS_FILTER_SYSTEM)
+		return IsCatalogRelationOid(relid);
+	if (evs_track_relations_bits == EVS_FILTER_USER)
+		return !IsCatalogRelationOid(relid);
+	return true;
+}
+
+/*
+ * Decide whether to track statistics for databases.
+ * Database statistics is tracked if it is in the track list or a special filter is enabled.
+ */
+static bool
+evs_should_track_database_statistics(Oid dboid)
+{
+	evs_track_hash_ensure_init();
+
+	if (evs_track_databases_from_list &&
+		!evs_oid_in_list(evs_track_databases_hash, dboid))
+		return false;
+	if ((evs_track_bits & EVS_TRACK_DATABASES) == 0)
+		return false;			/* relations-only mode */
+	if (evs_track_bits == EVS_TRACK_DATABASES)
+		return true;			/* databases-only, accumulate to db */
+	return true;
+}
+
+
+/* Accumulate common counts for database-level stats. */
+static inline void
+pgstat_accumulate_common_for_db(PgStat_CommonCounts * dst,
+								const PgStat_CommonCounts * src)
+{
+	pgstat_accumulate_common(dst, src);
+}
+
+/*
+ * Store incoming vacuum stats into pgstat custom statistics.
+ * store_relation: create/update per-relation entry
+ * store_db: accumulate into database-level entry (dboid, objid=0).
+ * Uses pgstat_get_entry_ref_locked and pgstat_accumulate_* for atomic updates.
+ */
+static void
+extvac_store(Oid dboid, Oid relid, int type,
+			 PgStat_VacuumRelationCounts * params,
+			 bool store_relation, bool store_db)
+{
+	PgStat_EntryRef *entry_ref;
+	PgStatShared_ExtVacEntry *shared;
+	uint64		objid;
+
+	if (!evs_enabled)
+		return;
+
+	if (store_relation)
+	{
+		objid = EXTVAC_OBJID(relid, type);
+		entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_EXTVAC_RELATION, dboid, objid, false);
+		if (entry_ref)
+		{
+			shared = (PgStatShared_ExtVacEntry *) entry_ref->shared_stats;
+			if (shared->stats.type == PGSTAT_EXTVAC_INVALID)
+			{
+				memset(&shared->stats, 0, sizeof(shared->stats));
+				shared->stats.type = params->type;
+			}
+			pgstat_accumulate_extvac_stats(&shared->stats, params);
+			pgstat_unlock_entry(entry_ref);
+		}
+	}
+
+	if (store_db)
+	{
+		entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_EXTVAC_DB, dboid, InvalidOid, false);
+		if (entry_ref)
+		{
+			shared = (PgStatShared_ExtVacEntry *) entry_ref->shared_stats;
+			if (shared->stats.type == PGSTAT_EXTVAC_INVALID)
+			{
+				memset(&shared->stats, 0, sizeof(shared->stats));
+				shared->stats.type = PGSTAT_EXTVAC_DB;
+			}
+			pgstat_accumulate_common_for_db(&shared->stats.common, &params->common);
+			pgstat_unlock_entry(entry_ref);
+		}
+	}
+}
+
+/*
+ * Vacuum report hook: called when vacuum finishes. Filters by track settings,
+ * stores stats per-relation and/or per-database, then chains to previous hook.
+ */
+static void
+pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
+							  PgStat_VacuumRelationCounts * params)
+{
+	Oid			dboid = shared ? InvalidOid : MyDatabaseId;
+	bool		store_relation;
+	bool		store_db;
+
+	if (evs_enabled)
+	{
+		store_relation = evs_should_track_relation_statistics(dboid, tableoid);
+		store_db = evs_should_track_database_statistics(dboid);
+
+		if (store_relation || store_db)
+			extvac_store(dboid, tableoid, params->type, params, store_relation, store_db);
+	}
+	if (prev_report_vacuum_hook)
+		prev_report_vacuum_hook(tableoid, shared, params);
+}
+
+/* Reset statistics for a single relation entry. */
+static bool
+extvac_reset_by_relid(Oid dboid, Oid relid, int type)
+{
+	uint64		objid = EXTVAC_OBJID(relid, type);
+
+	pgstat_reset_entry(PGSTAT_KIND_EXTVAC_RELATION, dboid, objid, 0);
+	return true;
+}
+
+/* Callback for pgstat_reset_matching_entries: match relation entries for given db */
+static bool
+match_extvac_relations_for_db(PgStatShared_HashEntry *entry, Datum match_data)
+{
+	return entry->key.kind == PGSTAT_KIND_EXTVAC_RELATION &&
+		entry->key.dboid == DatumGetObjectId(match_data);
+}
+
+/*
+ * Reset statistics for a database (aggregate entry) and all its relations.
+ */
+static int64
+extvac_database_reset(Oid dboid)
+{
+	pgstat_reset_matching_entries(match_extvac_relations_for_db,
+								  ObjectIdGetDatum(dboid), 0);
+	pgstat_reset_entry(PGSTAT_KIND_EXTVAC_DB, dboid, 0, 0);
+	return 1;
+}
+
+/* Reset all vacuum statistics (both relation and database entries). */
+static int64
+extvac_stat_reset(void)
+{
+	pgstat_reset_of_kind(PGSTAT_KIND_EXTVAC_RELATION);
+	pgstat_reset_of_kind(PGSTAT_KIND_EXTVAC_DB);
+	return 0;					/* count not available */
+}
+
+PG_FUNCTION_INFO_V1(vacuum_statistics_reset);
+PG_FUNCTION_INFO_V1(extvac_shared_memory_size);
+PG_FUNCTION_INFO_V1(extvac_reset_entry);
+PG_FUNCTION_INFO_V1(extvac_reset_db_entry);
+
+Datum
+vacuum_statistics_reset(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_INT64(extvac_stat_reset());
+}
+
+Datum
+extvac_reset_entry(PG_FUNCTION_ARGS)
+{
+	Oid			dboid = PG_GETARG_OID(0);
+	Oid			relid = PG_GETARG_OID(1);
+	int			type = PG_GETARG_INT32(2);
+
+	PG_RETURN_BOOL(extvac_reset_by_relid(dboid, relid, type));
+}
+
+Datum
+extvac_reset_db_entry(PG_FUNCTION_ARGS)
+{
+	Oid			dboid = PG_GETARG_OID(0);
+
+	PG_RETURN_INT64(extvac_database_reset(dboid));
+}
+
+/*
+ * Return total shared memory in bytes used by the extension for vacuum stats.
+ * Used for monitoring and capacity planning: memory grows with the number of
+ * tracked relations and databases.
+ */
+Datum
+extvac_shared_memory_size(PG_FUNCTION_ARGS)
+{
+	uint64		rel_count;
+	uint64		db_count;
+	uint64		total;
+	size_t		entry_size = sizeof(PgStatShared_ExtVacEntry);
+
+	rel_count = pgstat_get_entry_count(PGSTAT_KIND_EXTVAC_RELATION);
+	db_count = pgstat_get_entry_count(PGSTAT_KIND_EXTVAC_DB);
+	total = rel_count + db_count;
+
+	PG_RETURN_INT64((int64) (total * entry_size));
+}
+
+/*
+ * Track list management: add/remove database or relation OIDs.
+ * Changes are persisted to pg_stat/ext_vacuum_statistics_track.oid.
+ */
+
+PG_FUNCTION_INFO_V1(evs_add_track_database);
+PG_FUNCTION_INFO_V1(evs_remove_track_database);
+PG_FUNCTION_INFO_V1(evs_add_track_relation);
+PG_FUNCTION_INFO_V1(evs_remove_track_relation);
+
+/*
+ * Mutating track-list entry points: require server-wide privilege, since
+ * the underlying lists steer tracking for every backend.
+ */
+static void
+evs_require_track_privilege(const char *funcname)
+{
+	if (!superuser() && !has_privs_of_role(GetUserId(), ROLE_PG_READ_ALL_STATS))
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("permission denied for function %s", funcname),
+				 errhint("Only superusers and members of pg_read_all_stats "
+						 "may change the vacuum statistics track list.")));
+}
+
+Datum
+evs_add_track_database(PG_FUNCTION_ARGS)
+{
+	Oid			oid = PG_GETARG_OID(0);
+	bool		found;
+	LWLock	   *lock;
+
+	evs_require_track_privilege("add_track_database");
+	lock = evs_get_track_lock();
+	LWLockAcquire(lock, LW_EXCLUSIVE);
+	evs_track_hash_ensure_init();
+	hash_search(evs_track_databases_hash, &oid, HASH_ENTER, &found);
+	evs_track_save_file();
+	LWLockRelease(lock);
+	PG_RETURN_BOOL(!found);		/* true if newly added */
+}
+
+Datum
+evs_remove_track_database(PG_FUNCTION_ARGS)
+{
+	Oid			oid = PG_GETARG_OID(0);
+	bool		found;
+	LWLock	   *lock;
+
+	evs_require_track_privilege("remove_track_database");
+	lock = evs_get_track_lock();
+	LWLockAcquire(lock, LW_EXCLUSIVE);
+	evs_track_hash_ensure_init();
+	hash_search(evs_track_databases_hash, &oid, HASH_REMOVE, &found);
+	evs_track_save_file();
+	LWLockRelease(lock);
+	PG_RETURN_BOOL(found);
+}
+
+Datum
+evs_add_track_relation(PG_FUNCTION_ARGS)
+{
+	EvsTrackRelKey key;
+	bool		found;
+	LWLock	   *lock;
+
+	evs_require_track_privilege("add_track_relation");
+	key.dboid = PG_GETARG_OID(0);
+	key.reloid = PG_GETARG_OID(1);
+	lock = evs_get_track_lock();
+	LWLockAcquire(lock, LW_EXCLUSIVE);
+	evs_track_hash_ensure_init();
+	hash_search(evs_track_relations_hash, &key, HASH_ENTER, &found);
+	evs_track_save_file();
+	LWLockRelease(lock);
+	PG_RETURN_BOOL(!found);		/* true if newly added */
+}
+
+Datum
+evs_remove_track_relation(PG_FUNCTION_ARGS)
+{
+	EvsTrackRelKey key;
+	bool		found;
+	LWLock	   *lock;
+
+	evs_require_track_privilege("remove_track_relation");
+	key.dboid = PG_GETARG_OID(0);
+	key.reloid = PG_GETARG_OID(1);
+	lock = evs_get_track_lock();
+	LWLockAcquire(lock, LW_EXCLUSIVE);
+	evs_track_hash_ensure_init();
+	hash_search(evs_track_relations_hash, &key, HASH_REMOVE, &found);
+	evs_track_save_file();
+	LWLockRelease(lock);
+	PG_RETURN_BOOL(found);
+}
+
+/*
+ * Returns the list of database and relation OIDs for which statistics
+ * are collected.
+ */
+PG_FUNCTION_INFO_V1(evs_track_list);
+
+Datum
+evs_track_list(PG_FUNCTION_ARGS)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	TupleDesc	tupdesc;
+	Tuplestorestate *tupstore;
+	MemoryContext per_query_ctx;
+	MemoryContext oldcontext;
+	Datum		values[3];
+	bool		nulls[3] = {false, false, false};
+	HASH_SEQ_STATUS status;
+	Oid		   *entry;
+	EvsTrackRelKey *rel_entry;
+
+	if (!rsinfo || !IsA(rsinfo, ReturnSetInfo))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics: set-valued function called in context that cannot accept a set")));
+	if (!(rsinfo->allowedModes & SFRM_Materialize))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics: materialize mode required")));
+
+	evs_track_hash_ensure_init();
+
+	per_query_ctx = rsinfo->econtext->ecxt_per_query_memory;
+	oldcontext = MemoryContextSwitchTo(per_query_ctx);
+
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "ext_vacuum_statistics: return type must be a row type");
+
+	tupstore = tuplestore_begin_heap(true, false, work_mem);
+	rsinfo->returnMode = SFRM_Materialize;
+	rsinfo->setResult = tupstore;
+	rsinfo->setDesc = tupdesc;
+
+	/* Databases */
+	if (hash_get_num_entries(evs_track_databases_hash) == 0)
+	{
+		values[0] = CStringGetTextDatum("database");
+		nulls[1] = true;
+		nulls[2] = true;
+		tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+		nulls[1] = false;
+		nulls[2] = false;
+	}
+	else
+	{
+		hash_seq_init(&status, evs_track_databases_hash);
+		while ((entry = (Oid *) hash_seq_search(&status)) != NULL)
+		{
+			values[0] = CStringGetTextDatum("database");
+			values[1] = ObjectIdGetDatum(*entry);
+			nulls[2] = true;
+			tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+			nulls[2] = false;
+		}
+	}
+
+	/* Relations */
+	if (hash_get_num_entries(evs_track_relations_hash) == 0)
+	{
+		values[0] = CStringGetTextDatum("relation");
+		nulls[1] = true;
+		nulls[2] = true;
+		tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+		nulls[1] = false;
+		nulls[2] = false;
+	}
+	else
+	{
+		hash_seq_init(&status, evs_track_relations_hash);
+		while ((rel_entry = (EvsTrackRelKey *) hash_seq_search(&status)) != NULL)
+		{
+			values[0] = CStringGetTextDatum("relation");
+			values[1] = ObjectIdGetDatum(rel_entry->dboid);
+			values[2] = ObjectIdGetDatum(rel_entry->reloid);
+			tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+		}
+	}
+
+	MemoryContextSwitchTo(oldcontext);
+
+	return (Datum) 0;
+}
+
+/*
+ * Output vacuum statistics (tables, indexes, or per-database aggregates).
+ */
+#define EXTVAC_COMMON_STAT_COLS 12
+
+static void
+tuplestore_put_common(PgStat_CommonCounts * vacuum_ext,
+					  Datum *values, bool *nulls, int *i)
+{
+	char		buf[256];
+	const int	base PG_USED_FOR_ASSERTS_ONLY = *i;
+
+	values[(*i)++] = Int64GetDatum(vacuum_ext->total_blks_read);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->total_blks_hit);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->total_blks_dirtied);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->total_blks_written);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->wal_records);
+	values[(*i)++] = Int64GetDatum(vacuum_ext->wal_fpi);
+	snprintf(buf, sizeof buf, UINT64_FORMAT, vacuum_ext->wal_bytes);
+	values[(*i)++] = DirectFunctionCall3(numeric_in,
+										 CStringGetDatum(buf),
+										 ObjectIdGetDatum(0),
+										 Int32GetDatum(-1));
+	values[(*i)++] = Float8GetDatum(vacuum_ext->blk_read_time);
+	values[(*i)++] = Float8GetDatum(vacuum_ext->blk_write_time);
+	values[(*i)++] = Float8GetDatum(vacuum_ext->delay_time);
+	values[(*i)++] = Float8GetDatum(vacuum_ext->total_time);
+	values[(*i)++] = Int32GetDatum(vacuum_ext->wraparound_failsafe_count);
+	Assert((*i - base) == EXTVAC_COMMON_STAT_COLS);
+}
+
+#define EXTVAC_HEAP_STAT_COLS	26
+#define EXTVAC_IDX_STAT_COLS	17
+#define EXTVAC_MAX_STAT_COLS	Max(EXTVAC_HEAP_STAT_COLS, EXTVAC_IDX_STAT_COLS)
+
+static void
+tuplestore_put_for_relation(Oid relid, Tuplestorestate *tupstore,
+							TupleDesc tupdesc, PgStat_VacuumRelationCounts * vacuum_ext)
+{
+	Datum		values[EXTVAC_MAX_STAT_COLS];
+	bool		nulls[EXTVAC_MAX_STAT_COLS];
+	int			i = 0;
+
+	memset(nulls, 0, sizeof(nulls));
+	values[i++] = ObjectIdGetDatum(relid);
+
+	tuplestore_put_common(&vacuum_ext->common, values, nulls, &i);
+	values[i++] = Int64GetDatum(vacuum_ext->common.blks_fetched - vacuum_ext->common.blks_hit);
+	values[i++] = Int64GetDatum(vacuum_ext->common.blks_hit);
+
+	if (vacuum_ext->type == PGSTAT_EXTVAC_TABLE)
+	{
+		values[i++] = Int64GetDatum(vacuum_ext->common.tuples_deleted);
+		values[i++] = Int64GetDatum(vacuum_ext->table.pages_scanned);
+		values[i++] = Int64GetDatum(vacuum_ext->table.pages_removed);
+		values[i++] = Int64GetDatum(vacuum_ext->table.vm_new_frozen_pages);
+		values[i++] = Int64GetDatum(vacuum_ext->table.vm_new_visible_pages);
+		values[i++] = Int64GetDatum(vacuum_ext->table.vm_new_visible_frozen_pages);
+		values[i++] = Int64GetDatum(vacuum_ext->table.tuples_frozen);
+		values[i++] = Int64GetDatum(vacuum_ext->table.recently_dead_tuples);
+		values[i++] = Int64GetDatum(vacuum_ext->table.index_vacuum_count);
+		values[i++] = Int64GetDatum(vacuum_ext->table.missed_dead_pages);
+		values[i++] = Int64GetDatum(vacuum_ext->table.missed_dead_tuples);
+	}
+	else if (vacuum_ext->type == PGSTAT_EXTVAC_INDEX)
+	{
+		values[i++] = Int64GetDatum(vacuum_ext->common.tuples_deleted);
+		values[i++] = Int64GetDatum(vacuum_ext->index.pages_deleted);
+	}
+
+	Assert(i == ((vacuum_ext->type == PGSTAT_EXTVAC_TABLE) ? EXTVAC_HEAP_STAT_COLS : EXTVAC_IDX_STAT_COLS));
+	tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+}
+
+static Datum
+pg_stats_vacuum(FunctionCallInfo fcinfo, int type)
+{
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	MemoryContext per_query_ctx;
+	MemoryContext oldcontext;
+	Tuplestorestate *tupstore;
+	TupleDesc	tupdesc;
+	Oid			dbid = PG_GETARG_OID(0);
+
+	if (rsinfo == NULL || !IsA(rsinfo, ReturnSetInfo))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics: set-valued function called in context that cannot accept a set")));
+	if (!(rsinfo->allowedModes & SFRM_Materialize))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("ext_vacuum_statistics: materialize mode required")));
+
+	per_query_ctx = rsinfo->econtext->ecxt_per_query_memory;
+	oldcontext = MemoryContextSwitchTo(per_query_ctx);
+
+	if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
+		elog(ERROR, "ext_vacuum_statistics: return type must be a row type");
+
+	tupstore = tuplestore_begin_heap(true, false, work_mem);
+	rsinfo->returnMode = SFRM_Materialize;
+	rsinfo->setResult = tupstore;
+	rsinfo->setDesc = tupdesc;
+
+	MemoryContextSwitchTo(oldcontext);
+
+	if (type == PGSTAT_EXTVAC_INDEX || type == PGSTAT_EXTVAC_TABLE)
+	{
+		Oid			relid = PG_GETARG_OID(1);
+		PgStat_VacuumRelationCounts *stats;
+
+		if (!OidIsValid(relid))
+			return (Datum) 0;
+
+		stats = (PgStat_VacuumRelationCounts *)
+			pgstat_fetch_entry(PGSTAT_KIND_EXTVAC_RELATION, dbid,
+							   EXTVAC_OBJID(relid, type), NULL);
+
+		if (!stats)
+			stats = (PgStat_VacuumRelationCounts *)
+				pgstat_fetch_entry(PGSTAT_KIND_EXTVAC_RELATION, InvalidOid,
+								   EXTVAC_OBJID(relid, type), NULL);
+
+		if (stats && stats->type == type)
+			tuplestore_put_for_relation(relid, tupstore, tupdesc, stats);
+	}
+	else if (type == PGSTAT_EXTVAC_DB)
+	{
+		if (OidIsValid(dbid))
+		{
+#define EXTVAC_DB_STAT_COLS 14
+			Datum		values[EXTVAC_DB_STAT_COLS];
+			bool		nulls[EXTVAC_DB_STAT_COLS];
+			int			i = 0;
+			PgStat_VacuumRelationCounts *stats;
+
+			stats = (PgStat_VacuumRelationCounts *)
+				pgstat_fetch_entry(PGSTAT_KIND_EXTVAC_DB, dbid,
+								   InvalidOid, NULL);
+			if (stats && stats->type == PGSTAT_EXTVAC_DB)
+			{
+				memset(nulls, 0, sizeof(nulls));
+				values[i++] = ObjectIdGetDatum(dbid);
+				tuplestore_put_common(&stats->common, values, nulls, &i);
+				values[i++] = Int32GetDatum(stats->common.interrupts_count);
+				Assert(i == EXTVAC_DB_STAT_COLS);
+				tuplestore_putvalues(tupstore, tupdesc, values, nulls);
+			}
+		}
+		/* invalid dbid: return empty set */
+	}
+	else
+		elog(PANIC, "ext_vacuum_statistics: invalid type %d", type);
+
+	return (Datum) 0;
+}
+
+PG_FUNCTION_INFO_V1(pg_stats_get_vacuum_tables);
+PG_FUNCTION_INFO_V1(pg_stats_get_vacuum_indexes);
+PG_FUNCTION_INFO_V1(pg_stats_get_vacuum_database);
+
+Datum
+pg_stats_get_vacuum_tables(PG_FUNCTION_ARGS)
+{
+	return pg_stats_vacuum(fcinfo, PGSTAT_EXTVAC_TABLE);
+}
+
+Datum
+pg_stats_get_vacuum_indexes(PG_FUNCTION_ARGS)
+{
+	return pg_stats_vacuum(fcinfo, PGSTAT_EXTVAC_INDEX);
+}
+
+Datum
+pg_stats_get_vacuum_database(PG_FUNCTION_ARGS)
+{
+	return pg_stats_vacuum(fcinfo, PGSTAT_EXTVAC_DB);
+}
diff --git a/contrib/meson.build b/contrib/meson.build
index ebb7f83d8c5..d7dc0fd07f0 100644
--- a/contrib/meson.build
+++ b/contrib/meson.build
@@ -26,6 +26,7 @@ subdir('cube')
 subdir('dblink')
 subdir('dict_int')
 subdir('dict_xsyn')
+subdir('ext_vacuum_statistics')
 subdir('earthdistance')
 subdir('file_fdw')
 subdir('fuzzystrmatch')
diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml
index b9b03654aad..2a38f9042bb 100644
--- a/doc/src/sgml/contrib.sgml
+++ b/doc/src/sgml/contrib.sgml
@@ -141,6 +141,7 @@ CREATE EXTENSION <replaceable>extension_name</replaceable>;
  &dict-int;
  &dict-xsyn;
  &earthdistance;
+ &extvacuumstatistics;
  &file-fdw;
  &fuzzystrmatch;
  &hstore;
diff --git a/doc/src/sgml/extvacuumstatistics.sgml b/doc/src/sgml/extvacuumstatistics.sgml
new file mode 100644
index 00000000000..75eb4691c4d
--- /dev/null
+++ b/doc/src/sgml/extvacuumstatistics.sgml
@@ -0,0 +1,502 @@
+<!-- doc/src/sgml/extvacuumstatistics.sgml -->
+
+<sect1 id="extvacuumstatistics" xreflabel="ext_vacuum_statistics">
+ <title>ext_vacuum_statistics &mdash; extended vacuum statistics</title>
+
+ <indexterm zone="extvacuumstatistics">
+  <primary>ext_vacuum_statistics</primary>
+ </indexterm>
+
+ <para>
+  The <filename>ext_vacuum_statistics</filename> module provides
+  extended per-table, per-index, and per-database vacuum statistics
+  (buffer I/O, WAL, general, timing) via views in the
+  <literal>ext_vacuum_statistics</literal> schema.
+ </para>
+
+ <para>
+  The module must be loaded by adding <literal>ext_vacuum_statistics</literal> to
+  <xref linkend="guc-shared-preload-libraries"/> in
+  <filename>postgresql.conf</filename>, because it registers a vacuum hook at
+  server startup.  This means that a server restart is needed to add or remove
+  the module.  After installation, run
+  <command>CREATE EXTENSION ext_vacuum_statistics</command> in each database
+  where you want to use it.
+ </para>
+
+ <para>
+  When active, the module provides views
+  <structname>ext_vacuum_statistics.pg_stats_vacuum_tables</structname>,
+  <structname>ext_vacuum_statistics.pg_stats_vacuum_indexes</structname>, and
+  <structname>ext_vacuum_statistics.pg_stats_vacuum_database</structname>,
+  plus functions to reset statistics and manage tracking.
+ </para>
+
+ <para>
+  Each tracked object (one table, one index, or one database) uses
+  approximately 232 bytes of shared memory on Linux x86_64 (e.g. Ubuntu):
+  common stats (buffers, WAL, timing) plus header and LWLock ~144 bytes;
+  type + union ~88 bytes (the union holds table-specific or index-specific
+  fields; the allocated size is the same for both).  The exact size depends on the platform.  Call
+  <function>ext_vacuum_statistics.shared_memory_size()</function> to get
+  the total shared memory used by the extension.  The extension's GUCs allow controlling memory by limiting
+  which objects are tracked:
+  <varname>vacuum_statistics.object_types</varname>,
+  <varname>vacuum_statistics.track_relations</varname>, and
+  <varname>track_*_from_list</varname>.
+  Example: a database with 1000 tables and 2000 indexes uses about 700 KB
+  on Ubuntu ((1000 + 2000 + 1) × 232 bytes).
+ </para>
+
+ <sect2 id="extvacuumstatistics-pg-stats-vacuum-tables">
+  <title>The <structname>ext_vacuum_statistics.pg_stats_vacuum_tables</structname> View</title>
+
+  <indexterm zone="extvacuumstatistics">
+   <secondary>pg_stats_vacuum_tables</secondary>
+  </indexterm>
+
+  <para>
+   The view <structname>ext_vacuum_statistics.pg_stats_vacuum_tables</structname>
+   contains one row for each table in the current database (including TOAST
+   tables), showing statistics about vacuuming that specific table.  The columns
+   are shown in <xref linkend="extvacuumstatistics-pg-stats-vacuum-tables-columns"/>.
+  </para>
+
+  <table id="extvacuumstatistics-pg-stats-vacuum-tables-columns">
+   <title><structname>ext_vacuum_statistics.pg_stats_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>relid</structfield> <type>oid</type>
+      </para>
+      <para>
+       OID of a table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>schema</structfield> <type>name</type>
+      </para>
+      <para>
+       Name of the schema this 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 this table
+      </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 containing this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_read</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of database blocks read by vacuum operations performed on this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_hit</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of times database blocks were found in the buffer cache by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_dirtied</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of database blocks dirtied by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_blks_written</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of database blocks written by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_records</structfield> <type>int8</type>
+      </para>
+      <para>
+       Total number of WAL records generated by vacuum operations performed on this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wal_fpi</structfield> <type>int8</type>
+      </para>
+      <para>
+       Total number of WAL full page images generated by vacuum operations
+      </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 bytes generated by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_read_time</structfield> <type>float8</type>
+      </para>
+      <para>
+       Time spent reading blocks by vacuum operations, in milliseconds
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>blk_write_time</structfield> <type>float8</type>
+      </para>
+      <para>
+       Time spent writing blocks by vacuum operations, in milliseconds
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>delay_time</structfield> <type>float8</type>
+      </para>
+      <para>
+       Time spent in vacuum delay points, in milliseconds
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>total_time</structfield> <type>float8</type>
+      </para>
+      <para>
+       Total time of vacuuming this table, in milliseconds
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wraparound_failsafe_count</structfield> <type>int4</type>
+      </para>
+      <para>
+       Number of times vacuum was run to prevent a wraparound problem
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_read</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of blocks vacuum operations read from this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rel_blks_hit</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of times blocks of this table were found in the buffer cache by vacuum
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_deleted</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of dead tuples vacuum operations deleted from this table
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_scanned</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages examined by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>pages_removed</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages removed from physical storage by vacuum operations
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_frozen_pages</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages newly set all-frozen by vacuum in the visibility map
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_visible_pages</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages newly set all-visible by vacuum in the visibility map
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>vm_new_visible_frozen_pages</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages newly set all-visible and all-frozen by vacuum in the visibility map
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tuples_frozen</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of tuples that vacuum operations marked as frozen
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>recently_dead_tuples</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of dead tuples left due to visibility in transactions
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>index_vacuum_count</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of times indexes on this table were vacuumed
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>missed_dead_pages</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of pages that had at least one missed dead tuple
+      </para></entry>
+     </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>missed_dead_tuples</structfield> <type>int8</type>
+      </para>
+      <para>
+       Number of fully DEAD tuples that could not be pruned due to failure to acquire a cleanup lock
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect2>
+
+ <sect2 id="extvacuumstatistics-pg-stats-vacuum-indexes">
+  <title>The <structname>ext_vacuum_statistics.pg_stats_vacuum_indexes</structname> View</title>
+
+  <indexterm zone="extvacuumstatistics">
+   <secondary>pg_stats_vacuum_indexes</secondary>
+  </indexterm>
+
+  <para>
+   The view <structname>ext_vacuum_statistics.pg_stats_vacuum_indexes</structname>
+   contains one row for each index in the current database, showing statistics
+   about vacuuming that specific index.  Columns include
+   <structfield>indexrelid</structfield>, <structfield>schema</structfield>,
+   <structfield>indexrelname</structfield>, <structfield>dbname</structfield>,
+   buffer I/O (<structfield>total_blks_read</structfield>,
+   <structfield>total_blks_hit</structfield>, etc.), WAL
+   (<structfield>wal_records</structfield>, <structfield>wal_fpi</structfield>,
+   <structfield>wal_bytes</structfield>), timing
+   (<structfield>blk_read_time</structfield>, <structfield>blk_write_time</structfield>,
+   <structfield>delay_time</structfield>, <structfield>total_time</structfield>),
+   and <structfield>tuples_deleted</structfield>, <structfield>pages_deleted</structfield>.
+  </para>
+ </sect2>
+
+ <sect2 id="extvacuumstatistics-pg-stats-vacuum-database">
+  <title>The <structname>ext_vacuum_statistics.pg_stats_vacuum_database</structname> View</title>
+
+  <indexterm zone="extvacuumstatistics">
+   <secondary>pg_stats_vacuum_database</secondary>
+  </indexterm>
+
+  <para>
+   The view <structname>ext_vacuum_statistics.pg_stats_vacuum_database</structname>
+   contains one row for each database in the cluster, showing aggregate vacuum
+   statistics for that database.  Columns include
+   <structfield>dboid</structfield>, <structfield>dbname</structfield>,
+   <structfield>db_blks_read</structfield>, <structfield>db_blks_hit</structfield>,
+   <structfield>db_blks_dirtied</structfield>, <structfield>db_blks_written</structfield>,
+   WAL stats (<structfield>db_wal_records</structfield>,
+   <structfield>db_wal_fpi</structfield>, <structfield>db_wal_bytes</structfield>),
+   timing (<structfield>db_blk_read_time</structfield>,
+   <structfield>db_blk_write_time</structfield>, <structfield>db_delay_time</structfield>,
+   <structfield>db_total_time</structfield>),
+   <structfield>db_wraparound_failsafe_count</structfield>, and
+   <structfield>interrupts_count</structfield>.
+  </para>
+ </sect2>
+
+ <sect2 id="extvacuumstatistics-functions">
+  <title>Functions</title>
+
+  <variablelist>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.shared_memory_size()</function>
+     <returnvalue>bigint</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Returns the total shared memory in bytes used by the extension for
+      vacuum statistics (relations plus databases).
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.vacuum_statistics_reset()</function>
+     <returnvalue>bigint</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Resets all vacuum statistics.  Returns the number of entries reset.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.add_track_database(dboid oid)</function>
+     <returnvalue>boolean</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Adds a database OID to the tracking list (persisted to
+      <filename>pg_stat/ext_vacuum_statistics_track.oid</filename>).
+      Returns true if newly added.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.remove_track_database(dboid oid)</function>
+     <returnvalue>boolean</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Removes a database OID from the tracking list.  Returns true if found and removed.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.add_track_relation(dboid oid, reloid oid)</function>
+     <returnvalue>boolean</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Adds a (database, relation) OID pair to the tracking list.  Returns true if newly added.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.remove_track_relation(dboid oid, reloid oid)</function>
+     <returnvalue>boolean</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Removes a (database, relation) pair from the tracking list.  Returns true if found and removed.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term>
+     <function>ext_vacuum_statistics.track_list()</function>
+     <returnvalue>TABLE(track_kind text, dboid oid, reloid oid)</returnvalue>
+    </term>
+    <listitem>
+     <para>
+      Returns the list of database and relation OIDs for which vacuum statistics
+      are collected.  When <structfield>dboid</structfield> or
+      <structfield>reloid</structfield> is NULL, statistics are collected for all.
+     </para>
+    </listitem>
+   </varlistentry>
+  </variablelist>
+ </sect2>
+
+ <sect2 id="extvacuumstatistics-configuration">
+  <title>Configuration Parameters</title>
+
+  <variablelist>
+   <varlistentry>
+    <term><varname>vacuum_statistics.enabled</varname> (<type>boolean</type>)</term>
+    <listitem>
+     <para>
+      Enables extended vacuum statistics collection.  Default: <literal>on</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term><varname>vacuum_statistics.object_types</varname> (<type>string</type>)</term>
+    <listitem>
+     <para>
+      Object types for statistics: <literal>all</literal>, <literal>databases</literal>, or
+      <literal>relations</literal>.  Default: <literal>all</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term><varname>vacuum_statistics.track_relations</varname> (<type>string</type>)</term>
+    <listitem>
+     <para>
+      When tracking relations: <literal>all</literal>, <literal>system</literal>, or
+      <literal>user</literal>.  Default: <literal>all</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term><varname>vacuum_statistics.track_databases_from_list</varname> (<type>boolean</type>)</term>
+    <listitem>
+     <para>
+      If on, track only databases added via <function>add_track_database</function>.
+      Default: <literal>off</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+   <varlistentry>
+    <term><varname>vacuum_statistics.track_relations_from_list</varname> (<type>boolean</type>)</term>
+    <listitem>
+     <para>
+      If on, track only relations added via <function>add_track_relation</function>.
+      Default: <literal>off</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+  </variablelist>
+ </sect2>
+</sect1>
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 25a85082759..85d721467c0 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -133,6 +133,7 @@
 <!ENTITY dict-xsyn       SYSTEM "dict-xsyn.sgml">
 <!ENTITY dummy-seclabel  SYSTEM "dummy-seclabel.sgml">
 <!ENTITY earthdistance   SYSTEM "earthdistance.sgml">
+<!ENTITY extvacuumstatistics SYSTEM "extvacuumstatistics.sgml">
 <!ENTITY file-fdw        SYSTEM "file-fdw.sgml">
 <!ENTITY fuzzystrmatch   SYSTEM "fuzzystrmatch.sgml">
 <!ENTITY hstore          SYSTEM "hstore.sgml">
-- 
2.39.5 (Apple Git-154)



view thread (75+ messages)

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