v39-0003-ext_vacuum_statistics-extension-for-extended-vacuum-.patch

text/plain

Filename: v39-0003-ext_vacuum_statistics-extension-for-extended-vacuum-.patch
Type: text/plain
Part: 2
Message: Re: Vacuum statistics

Patch

Same data as JSON: GET /api/v1/attachments/:id/patch the parsed metadata as JSON — format, series position, per-file stats; never the diff bytes. API reference →
Format: format-patch
Series: patch v39-0003
Subject: ext_vacuum_statistics: extension for extended vacuum statistics
File+
contrib/ext_vacuum_statistics/expected/ext_vacuum_statistics.out 52 0
contrib/ext_vacuum_statistics/expected/vacuum-extending-in-repetable-read.out 52 0
contrib/ext_vacuum_statistics/ext_vacuum_statistics--1.0.sql 272 0
contrib/ext_vacuum_statistics/ext_vacuum_statistics.conf 2 0
contrib/ext_vacuum_statistics/ext_vacuum_statistics.control 5 0
contrib/ext_vacuum_statistics/Makefile 24 0
contrib/ext_vacuum_statistics/meson.build 41 0
contrib/ext_vacuum_statistics/README.md 165 0
contrib/ext_vacuum_statistics/specs/vacuum-extending-in-repetable-read.spec 59 0
contrib/ext_vacuum_statistics/t/052_vacuum_extending_basic_test.pl 780 0
contrib/ext_vacuum_statistics/t/053_vacuum_extending_freeze_test.pl 285 0
contrib/ext_vacuum_statistics/t/054_vacuum_extending_gucs_test.pl 279 0
contrib/ext_vacuum_statistics/vacuum_statistics.c 1387 0
contrib/Makefile 1 0
contrib/meson.build 1 0
doc/src/sgml/contrib.sgml 1 0
doc/src/sgml/extvacuumstatistics.sgml 502 0
doc/src/sgml/filelist.sgml 1 0
From 3011a3cfd9ee3d6e4d1c5a12e3d9984f6b6a194e Mon Sep 17 00:00:00 2001
From: Alena Rybakina <alenka.rybakina@gmail.com>
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)