0005-Extended-vacuum-statistics-interrupted-vacuums-and-t.patch

text/plain

Filename: 0005-Extended-vacuum-statistics-interrupted-vacuums-and-t.patch
Type: text/plain
Part: 4
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 0005
Subject: Extended vacuum statistics: interrupted vacuums and the wraparound failsafe
File+
doc/src/sgml/system-views.sgml 25 0
src/backend/access/heap/vacuumlazy.c 15 5
src/backend/catalog/system_views.sql 4 1
src/backend/utils/activity/pgstat_vacuum.c 46 0
src/backend/utils/adt/pgstatfuncs.c 6 2
src/backend/utils/error/elog.c 17 0
src/include/catalog/pg_proc.dat 6 6
src/include/pgstat.h 7 0
src/include/utils/elog.h 1 0
src/test/modules/test_misc/meson.build 1 0
src/test/modules/test_misc/t/015_vacuum_stats_interrupts.pl 71 0
src/test/modules/xid_wraparound/meson.build 1 0
src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl 66 0
src/test/regress/expected/rules.out 6 3
src/test/regress/expected/vacuum_stats.out 15 3
src/test/regress/sql/vacuum_stats.sql 8 0
From 71989a7bdc2f330f9d6f8aff07a4fba216df9005 Mon Sep 17 00:00:00 2001
From: Alena Rybakina <alenka.rybakina@gmail.com>
Date: Wed, 17 Jun 2026 21:32:19 +0300
Subject: [PATCH 5/9] Extended vacuum statistics: interrupted vacuums and the
 wraparound failsafe

Expose two database-/relation-level reliability counters with documentation
and regression coverage:

  wraparound_failsafe  whether (per relation) or how many times (per
                       database) a vacuum engaged the wraparound failsafe
  interrupts_count     number of times a vacuum of a table in the database
                       was interrupted by an error (database aggregate only)

The wraparound failsafe is recorded as a per-relation flag and summed into
a count at the database level.  interrupts_count is reported from the vacuum
error callback straight to shared memory, because an interrupted vacuum
aborts its transaction and a pending entry might never be flushed; it is a
database-wide counter only.  The positive wraparound_failsafe path is
covered by a TAP test under src/test/modules/xid_wraparound and the
interrupted-vacuum path by a TAP test under src/test/modules/test_misc.
---
 doc/src/sgml/system-views.sgml                | 25 +++++++
 src/backend/access/heap/vacuumlazy.c          | 20 ++++--
 src/backend/catalog/system_views.sql          |  5 +-
 src/backend/utils/activity/pgstat_vacuum.c    | 46 ++++++++++++
 src/backend/utils/adt/pgstatfuncs.c           |  8 ++-
 src/backend/utils/error/elog.c                | 17 +++++
 src/include/catalog/pg_proc.dat               | 12 ++--
 src/include/pgstat.h                          |  7 ++
 src/include/utils/elog.h                      |  1 +
 src/test/modules/test_misc/meson.build        |  1 +
 .../t/015_vacuum_stats_interrupts.pl          | 71 +++++++++++++++++++
 src/test/modules/xid_wraparound/meson.build   |  1 +
 .../t/005_vacuum_stats_failsafe.pl            | 66 +++++++++++++++++
 src/test/regress/expected/rules.out           |  9 ++-
 src/test/regress/expected/vacuum_stats.out    | 18 ++++-
 src/test/regress/sql/vacuum_stats.sql         |  8 +++
 16 files changed, 295 insertions(+), 20 deletions(-)
 create mode 100644 src/test/modules/test_misc/t/015_vacuum_stats_interrupts.pl
 create mode 100644 src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 7c95dcea6b..8bca17f3ef 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -5892,6 +5892,14 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of dead tuples that the vacuum could not remove because it failed to acquire a cleanup lock on their page.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wraparound_failsafe</structfield> <type>integer</type>
+      </para>
+      <para>
+       Number of times the failsafe mechanism was triggered to prevent transaction ID wraparound during the vacuum.
+      </para></entry>
+     </row>
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>wal_records</structfield> <type>bigint</type>
@@ -6089,6 +6097,14 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Number of vacuum operations in this database that failed with an error.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>wraparound_failsafe</structfield> <type>integer</type>
+      </para>
+      <para>
+       Number of vacuum operations in this database that engaged the wraparound failsafe mechanism.
+      </para></entry>
+     </row>
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>wal_records</structfield> <type>bigint</type>
@@ -6113,6 +6129,15 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Total amount of WAL generated by vacuum operations in this database, in bytes.
       </para></entry>
      </row>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>interrupts_count</structfield> <type>integer</type>
+      </para>
+      <para>
+       Number of times a vacuum of a table in this database was interrupted by
+       an error.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c
index c06c538f57..5089656c18 100644
--- a/src/backend/access/heap/vacuumlazy.c
+++ b/src/backend/access/heap/vacuumlazy.c
@@ -412,9 +412,8 @@ typedef struct LVRelState
 	 */
 	BlockNumber eager_scan_remaining_fails;
 
-	int32		wraparound_failsafe_count;	/* number of emergency vacuums to
-											 * prevent anti-wraparound
-											 * shutdown */
+	bool		wraparound_failsafe;	/* did this vacuum engage the
+										 * wraparound failsafe? */
 
 	PgStat_VacuumRelationCounts extVacReportIdx;
 
@@ -630,6 +629,8 @@ accumulate_heap_vacuum_statistics(LVRelState *vacrel, PgStat_VacuumRelationCount
 	extVacStats->table.recently_dead_tuples = vacrel->recently_dead_tuples;
 	extVacStats->table.missed_dead_tuples = vacrel->missed_dead_tuples;
 	extVacStats->table.missed_dead_pages = vacrel->missed_dead_pages;
+	extVacStats->common.wraparound_failsafe_count = vacrel->wraparound_failsafe;
+
 	extVacStats->common.wal_bytes -= vacrel->extVacReportIdx.common.wal_bytes;
 	extVacStats->common.wal_fpi -= vacrel->extVacReportIdx.common.wal_fpi;
 	extVacStats->common.wal_records -= vacrel->extVacReportIdx.common.wal_records;
@@ -1030,7 +1031,7 @@ heap_vacuum_rel(Relation rel, const VacuumParams *params,
 	vacrel->aggressive = vacuum_get_cutoffs(rel, params, &vacrel->cutoffs);
 	vacrel->rel_pages = orig_rel_pages = RelationGetNumberOfBlocks(rel);
 	vacrel->vistest = GlobalVisTestFor(rel);
-	vacrel->wraparound_failsafe_count = 0;
+	vacrel->wraparound_failsafe = false;
 
 	/* Initialize state used to track oldest extant XID/MXID */
 	vacrel->NewRelfrozenXid = vacrel->cutoffs.OldestXmin;
@@ -3160,7 +3161,7 @@ lazy_check_wraparound_failsafe(LVRelState *vacrel)
 		int64		progress_val[3] = {0, 0, PROGRESS_VACUUM_MODE_FAILSAFE};
 
 		VacuumFailsafeActive = true;
-		vacrel->wraparound_failsafe_count++;
+		vacrel->wraparound_failsafe = true;
 
 		/*
 		 * Abandon use of a buffer access strategy to allow use of all of
@@ -4113,6 +4114,15 @@ vacuum_error_callback(void *arg)
 {
 	LVRelState *errinfo = arg;
 
+	/*
+	 * If an actual ERROR (not a lower-severity report that merely carries this
+	 * vacuum error context) is being raised while we have a relation in hand,
+	 * record at the database level that a vacuum was interrupted.  Any error
+	 * here aborts the vacuum, so the exact phase does not matter.
+	 */
+	if (errinfo->rel != NULL && geterrlevel() == ERROR)
+		pgstat_report_vacuum_error(errinfo->rel->rd_rel->relisshared);
+
 	switch (errinfo->phase)
 	{
 		case VACUUM_ERRCB_PHASE_SCAN_HEAP:
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 47d6aa6aea..837e78d292 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1573,6 +1573,7 @@ CREATE VIEW pg_stat_vacuum_tables AS
         S.recently_dead_tuples AS recently_dead_tuples,
         S.missed_dead_pages AS missed_dead_pages,
         S.missed_dead_tuples AS missed_dead_tuples,
+        S.wraparound_failsafe AS wraparound_failsafe,
         S.wal_records AS wal_records,
         S.wal_fpi AS wal_fpi,
         S.wal_bytes AS wal_bytes
@@ -1611,9 +1612,11 @@ CREATE VIEW pg_stat_vacuum_database AS
 
             S.errors AS errors,
 
+            S.wraparound_failsafe AS wraparound_failsafe,
             S.wal_records AS wal_records,
             S.wal_fpi AS wal_fpi,
-            S.wal_bytes AS wal_bytes
+            S.wal_bytes AS wal_bytes,
+            S.interrupts_count AS interrupts_count
     FROM
             pg_database D,
             LATERAL pg_stat_get_vacuum_database(D.oid) S;
diff --git a/src/backend/utils/activity/pgstat_vacuum.c b/src/backend/utils/activity/pgstat_vacuum.c
index 99c4932ded..8e099f3ade 100644
--- a/src/backend/utils/activity/pgstat_vacuum.c
+++ b/src/backend/utils/activity/pgstat_vacuum.c
@@ -43,6 +43,7 @@ pgstat_accumulate_common(PgStat_CommonCounts *dst, const PgStat_CommonCounts *sr
 	ACCUMULATE_FIELD(wal_bytes);
 
 	ACCUMULATE_FIELD(tuples_deleted);
+	ACCUMULATE_FIELD(interrupts_count);
 }
 
 /*
@@ -64,6 +65,13 @@ pgstat_accumulate_extvac_stats_relations(PgStat_VacuumRelationCounts *dst,
 
 	pgstat_accumulate_common(&dst->common, &src->common);
 
+	/*
+	 * The wraparound failsafe is a per-relation flag (0/1), not a running
+	 * count: reflect whether the latest vacuum of this relation engaged it,
+	 * rather than summing across vacuums.
+	 */
+	dst->common.wraparound_failsafe_count = src->common.wraparound_failsafe_count;
+
 	if (dst->type == PGSTAT_EXTVAC_TABLE)
 	{
 		ACCUMULATE_SUBFIELD(table, pages_scanned);
@@ -90,6 +98,12 @@ pgstat_accumulate_extvac_stats_db(PgStat_VacuumDBCounts *dst,
 		return;
 
 	pgstat_accumulate_common(&dst->common, &src->common);
+
+	/*
+	 * At the database level the failsafe is a count: how many relation vacuums
+	 * engaged the wraparound failsafe.
+	 */
+	dst->common.wraparound_failsafe_count += src->common.wraparound_failsafe_count;
 	dst->errors += src->errors;
 }
 
@@ -126,6 +140,38 @@ pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
 										  dboid, InvalidOid, NULL);
 	dbpending = (PgStat_VacuumDBCounts *) entry_ref->pending;
 	pgstat_accumulate_common(&dbpending->common, &params->common);
+	/* count this relation's failsafe flag into the database-wide total */
+	dbpending->common.wraparound_failsafe_count += params->common.wraparound_failsafe_count;
+}
+
+/*
+ * Report that a vacuum was interrupted by an error.
+ *
+ * This is a database-wide counter only: an interrupted vacuum aborts its
+ * transaction, so reporting per-relation would require creating a relation
+ * stats entry from the error path (which the aborting transaction may roll
+ * back).  Called from the vacuum error callback, we therefore update shared
+ * memory directly rather than going through pending entries, which might never
+ * be flushed.
+ *
+ * The database id is InvalidOid for shared relations, just as in
+ * pgstat_report_vacuum_extstats(); it must not be hard-coded to MyDatabaseId.
+ */
+void
+pgstat_report_vacuum_error(bool shared)
+{
+	PgStat_EntryRef *entry_ref;
+	PgStatShared_VacuumDB *shdbentry;
+	Oid			dboid = (shared ? InvalidOid : MyDatabaseId);
+
+	if (!pgstat_track_vacuum_statistics)
+		return;
+
+	entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_VACUUM_DB,
+											dboid, InvalidOid, false);
+	shdbentry = (PgStatShared_VacuumDB *) entry_ref->shared_stats;
+	shdbentry->stats.common.interrupts_count++;
+	pgstat_unlock_entry(entry_ref);
 }
 
 /*
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index aad4427469..7927668bf8 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2374,7 +2374,7 @@ pg_stat_have_stats(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 11
+#define PG_STAT_GET_VACUUM_TABLES_STATS_COLS 12
 
 	Oid			relid = PG_GETARG_OID(0);
 	PgStat_VacuumRelationCounts *extvacuum;
@@ -2408,6 +2408,7 @@ pg_stat_get_vacuum_tables(PG_FUNCTION_ARGS)
 	values[i++] = Int64GetDatum(extvacuum->table.recently_dead_tuples);
 	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_pages);
 	values[i++] = Int64GetDatum(extvacuum->table.missed_dead_tuples);
+	values[i++] = Int32GetDatum(extvacuum->common.wraparound_failsafe_count);
 	values[i++] = Int64GetDatum(extvacuum->common.wal_records);
 	values[i++] = Int64GetDatum(extvacuum->common.wal_fpi);
 	snprintf(buf, sizeof buf, UINT64_FORMAT, extvacuum->common.wal_bytes);
@@ -2476,7 +2477,7 @@ pg_stat_get_vacuum_indexes(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 5
+#define PG_STAT_GET_VACUUM_DATABASE_STATS_COLS 7
 
 	Oid			dbid = PG_GETARG_OID(0);
 	PgStat_VacuumDBCounts *extvacuum;
@@ -2500,6 +2501,8 @@ pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 
 	values[i++] = Int32GetDatum(extvacuum->errors);
 
+	values[i++] = Int32GetDatum(extvacuum->common.wraparound_failsafe_count);
+
 	values[i++] = Int64GetDatum(extvacuum->common.wal_records);
 	values[i++] = Int64GetDatum(extvacuum->common.wal_fpi);
 	snprintf(buf, sizeof buf, UINT64_FORMAT, extvacuum->common.wal_bytes);
@@ -2507,6 +2510,7 @@ pg_stat_get_vacuum_database(PG_FUNCTION_ARGS)
 									  CStringGetDatum(buf),
 									  ObjectIdGetDatum(0),
 									  Int32GetDatum(-1));
+	values[i++] = Int32GetDatum(extvacuum->common.interrupts_count);
 	Assert(i == PG_STAT_GET_VACUUM_DATABASE_STATS_COLS);
 
 	/* Returns the record as Datum */
diff --git a/src/backend/utils/error/elog.c b/src/backend/utils/error/elog.c
index a6936a0c66..04f5b0a819 100644
--- a/src/backend/utils/error/elog.c
+++ b/src/backend/utils/error/elog.c
@@ -1815,6 +1815,23 @@ getinternalerrposition(void)
 	return edata->internalpos;
 }
 
+/*
+ * geterrlevel --- return the elevel of the error currently being constructed
+ *
+ * This is only intended for use in error callback subroutines, where it lets
+ * a callback tell a genuine error apart from a lower-severity report.
+ */
+int
+geterrlevel(void)
+{
+	ErrorData  *edata = &errordata[errordata_stack_depth];
+
+	/* we don't bother incrementing recursion_depth */
+	CHECK_STACK_DEPTH();
+
+	return edata->elevel;
+}
+
 
 /*
  * Functions to allow construction of error message strings separately from
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 3dd3d81a13..51cd0e5ed1 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12643,9 +12643,9 @@
   proname => 'pg_stat_get_vacuum_tables', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,numeric}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,wal_records,wal_fpi,wal_bytes}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int4,int8,int8,numeric}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{reloid,relid,pages_scanned,pages_removed,tuples_deleted,tuples_frozen,recently_dead_tuples,missed_dead_pages,missed_dead_tuples,wraparound_failsafe,wal_records,wal_fpi,wal_bytes}',
   prosrc => 'pg_stat_get_vacuum_tables' }
 
 # oid8 related functions
@@ -12727,8 +12727,8 @@
   proname => 'pg_stat_get_vacuum_database', prorows => 1000, provolatile => 's', prorettype => 'record', proisstrict => 'f',
   proretset => 't',
   proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int4,int8,int8,numeric}',
-  proargmodes => '{i,o,o,o,o,o}',
-  proargnames => '{dbid,dboid,errors,wal_records,wal_fpi,wal_bytes}',
+  proallargtypes => '{oid,oid,int4,int4,int8,int8,numeric,int4}',
+  proargmodes => '{i,o,o,o,o,o,o,o}',
+  proargnames => '{dbid,dboid,errors,wraparound_failsafe,wal_records,wal_fpi,wal_bytes,interrupts_count}',
   prosrc => 'pg_stat_get_vacuum_database' },
 ]
diff --git a/src/include/pgstat.h b/src/include/pgstat.h
index f704a15003..d041359e72 100644
--- a/src/include/pgstat.h
+++ b/src/include/pgstat.h
@@ -182,6 +182,12 @@ typedef struct PgStat_CommonCounts
 
 	/* tuples */
 	int64		tuples_deleted;
+
+	/* failsafe */
+	int32		wraparound_failsafe_count;
+
+	/* number of times a vacuum of the object was interrupted by an error */
+	int32		interrupts_count;
 }			PgStat_CommonCounts;
 
 /* ----------
@@ -934,6 +940,7 @@ extern void pgstat_vacuum_relation_delete_pending_cb(Oid relid);
 extern void
 			pgstat_report_vacuum_extstats(Oid tableoid, bool shared,
 										  PgStat_VacuumRelationCounts * params);
+extern void pgstat_report_vacuum_error(bool shared);
 extern PgStat_VacuumRelationCounts * pgstat_fetch_stat_vacuum_tabentry(Oid relid, Oid dbid);
 extern PgStat_VacuumDBCounts * pgstat_fetch_stat_vacuum_dbentry(Oid dbid);
 
diff --git a/src/include/utils/elog.h b/src/include/utils/elog.h
index 6ae376ba00..7c1369524e 100644
--- a/src/include/utils/elog.h
+++ b/src/include/utils/elog.h
@@ -230,6 +230,7 @@ extern int	internalerrquery(const char *query);
 extern int	err_generic_string(int field, const char *str);
 
 extern int	geterrcode(void);
+extern int	geterrlevel(void);
 extern int	geterrposition(void);
 extern int	getinternalerrposition(void);
 
diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build
index 805c6c2c39..8761d79d70 100644
--- a/src/test/modules/test_misc/meson.build
+++ b/src/test/modules/test_misc/meson.build
@@ -23,6 +23,7 @@ tests += {
       't/012_ddlutils.pl',
       't/013_temp_obj_multisession.pl',
       't/014_vacuum_stats.pl',
+      't/015_vacuum_stats_interrupts.pl',
       't/016_vacuum_stats_parallel.pl',
     ],
     # The injection points are cluster-wide, so disable installcheck
diff --git a/src/test/modules/test_misc/t/015_vacuum_stats_interrupts.pl b/src/test/modules/test_misc/t/015_vacuum_stats_interrupts.pl
new file mode 100644
index 0000000000..80dd2b7b3c
--- /dev/null
+++ b/src/test/modules/test_misc/t/015_vacuum_stats_interrupts.pl
@@ -0,0 +1,71 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+
+# Test the interrupts_count counter of pg_stat_vacuum_database.
+#
+# interrupts_count records how many times a vacuum in the database was
+# interrupted by an error.  We provoke that by starting a vacuum that sleeps at
+# its cost-based delay points and canceling it, with pg_cancel_backend(), while
+# it is still running.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf(
+	'postgresql.conf', qq[
+autovacuum = off
+track_vacuum_statistics = on
+]);
+$node->start;
+
+# fillfactor = 10 spreads the rows over many pages so the vacuum hits enough
+# cost-delay points to stay running until we cancel it.
+$node->safe_psql(
+	'postgres', qq[
+CREATE TABLE vacstat_int (id int) WITH (autovacuum_enabled = off, fillfactor = 10);
+INSERT INTO vacstat_int SELECT generate_series(1, 1000);
+DELETE FROM vacstat_int;
+]);
+
+# Start a vacuum that sleeps at every cost-delay point, in the background.  The
+# \echo lets query_until() return as soon as the VACUUM has been launched.
+my $appname = 'vacuum_interrupt_test';
+my $vac = $node->background_psql('postgres', on_error_stop => 0);
+$vac->query_until(
+	qr/start/, qq[
+SET application_name = '$appname';
+SET vacuum_cost_delay = '100ms';
+SET vacuum_cost_limit = 1;
+\\echo start
+VACUUM vacstat_int;
+]);
+
+# Wait until the vacuum is actually running, then cancel it.
+$node->poll_query_until(
+	'postgres', qq[
+SELECT count(*) = 1 FROM pg_stat_activity
+ WHERE application_name = '$appname' AND query LIKE 'VACUUM%' AND state = 'active'])
+  or die "timed out waiting for the vacuum to start";
+
+my $cancelled = $node->safe_psql(
+	'postgres', qq[
+SELECT pg_cancel_backend(pid) FROM pg_stat_activity
+ WHERE application_name = '$appname' AND query LIKE 'VACUUM%']);
+is($cancelled, 't', 'canceled the running vacuum');
+
+$vac->quit;
+like($vac->{stderr}, qr/canceling statement due to user request/,
+	'vacuum canceled by user request');
+
+is( $node->safe_psql(
+		'postgres', qq[
+SELECT interrupts_count > 0 FROM pg_stat_vacuum_database WHERE dbname = current_database()]),
+	't',
+	'interrupts_count advanced in pg_stat_vacuum_database');
+
+$node->stop;
+done_testing();
diff --git a/src/test/modules/xid_wraparound/meson.build b/src/test/modules/xid_wraparound/meson.build
index 97ce670f9a..9224e59d1d 100644
--- a/src/test/modules/xid_wraparound/meson.build
+++ b/src/test/modules/xid_wraparound/meson.build
@@ -31,6 +31,7 @@ tests += {
       't/002_limits.pl',
       't/003_wraparounds.pl',
       't/004_notify_freeze.pl',
+      't/005_vacuum_stats_failsafe.pl',
     ],
   },
 }
diff --git a/src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl b/src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl
new file mode 100644
index 0000000000..eff77fe876
--- /dev/null
+++ b/src/test/modules/xid_wraparound/t/005_vacuum_stats_failsafe.pl
@@ -0,0 +1,66 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+
+# Test that the wraparound failsafe counter exposed by the extended vacuum
+# statistics views advances when a VACUUM engages the wraparound failsafe.
+#
+# The failsafe only triggers once a relation's age exceeds
+# max(vacuum_failsafe_age, autovacuum_freeze_max_age * 1.05), which cannot be
+# reached by an ordinary regression test.  Here we lower
+# autovacuum_freeze_max_age to its minimum and use the xid_wraparound
+# extension to burn enough transaction IDs to age the table past that
+# threshold, then check that the wraparound_failsafe counters advance.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bxid_wraparound\b/)
+{
+	plan skip_all => "test xid_wraparound not enabled in PG_TEST_EXTRA";
+}
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf(
+	'postgresql.conf', qq[
+autovacuum = off
+track_vacuum_statistics = on
+# Lower the wraparound-failsafe threshold as far as possible so that a modest
+# number of consumed XIDs is enough to engage the failsafe.
+autovacuum_freeze_max_age = 100000
+vacuum_failsafe_age = 0
+]);
+$node->start;
+$node->safe_psql('postgres', 'CREATE EXTENSION xid_wraparound');
+
+# A table whose relfrozenxid age we will push past the failsafe threshold.
+$node->safe_psql(
+	'postgres', qq[
+CREATE TABLE fs_tab (id int) WITH (autovacuum_enabled = off);
+INSERT INTO fs_tab SELECT generate_series(1, 1000);
+]);
+
+# Advance the XID counter well past the failsafe threshold.
+$node->safe_psql('postgres', 'SELECT consume_xids(200000)');
+
+# This VACUUM must engage the wraparound failsafe.
+$node->safe_psql('postgres', 'VACUUM fs_tab');
+
+# The per-table view records that the failsafe was engaged for this relation.
+my $tab = $node->safe_psql(
+	'postgres', qq[
+SELECT wraparound_failsafe > 0
+  FROM pg_stat_vacuum_tables WHERE relname = 'fs_tab']);
+is($tab, 't', 'wraparound_failsafe advanced in pg_stat_vacuum_tables');
+
+# The per-database aggregate counts the failsafe as well.
+my $db = $node->safe_psql(
+	'postgres', qq[
+SELECT wraparound_failsafe > 0
+  FROM pg_stat_vacuum_database WHERE dbname = current_database()]);
+is($db, 't', 'wraparound_failsafe advanced in pg_stat_vacuum_database');
+
+$node->stop;
+done_testing();
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 9b35f0779e..467c2e1843 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2424,11 +2424,13 @@ pg_stat_user_tables| SELECT relid,
 pg_stat_vacuum_database| SELECT d.oid AS dboid,
     d.datname AS dbname,
     s.errors,
+    s.wraparound_failsafe,
     s.wal_records,
     s.wal_fpi,
-    s.wal_bytes
+    s.wal_bytes,
+    s.interrupts_count
    FROM pg_database d,
-    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, wal_records, wal_fpi, wal_bytes);
+    LATERAL pg_stat_get_vacuum_database(d.oid) s(dboid, errors, wraparound_failsafe, wal_records, wal_fpi, wal_bytes, interrupts_count);
 pg_stat_vacuum_indexes| SELECT c.oid AS relid,
     i.oid AS indexrelid,
     n.nspname AS schemaname,
@@ -2455,12 +2457,13 @@ pg_stat_vacuum_tables| SELECT n.nspname AS schemaname,
     s.recently_dead_tuples,
     s.missed_dead_pages,
     s.missed_dead_tuples,
+    s.wraparound_failsafe,
     s.wal_records,
     s.wal_fpi,
     s.wal_bytes
    FROM (pg_class c
      JOIN pg_namespace n ON ((n.oid = c.relnamespace))),
-    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, wal_records, wal_fpi, wal_bytes)
+    LATERAL pg_stat_get_vacuum_tables(c.oid) s(relid, pages_scanned, pages_removed, tuples_deleted, tuples_frozen, recently_dead_tuples, missed_dead_pages, missed_dead_tuples, wraparound_failsafe, wal_records, wal_fpi, wal_bytes)
   WHERE (c.relkind = ANY (ARRAY['r'::"char", 't'::"char", 'm'::"char"]));
 pg_stat_wal| SELECT wal_records,
     wal_fpi,
diff --git a/src/test/regress/expected/vacuum_stats.out b/src/test/regress/expected/vacuum_stats.out
index 64639c75dc..c3024e5fc4 100644
--- a/src/test/regress/expected/vacuum_stats.out
+++ b/src/test/regress/expected/vacuum_stats.out
@@ -122,6 +122,17 @@ SELECT wal_records > 0 AS wal_records,
 (1 row)
 
 DROP TABLE vacstat_fpi;
+-- wraparound failsafe.  A normal vacuum does not engage the wraparound
+-- failsafe (wraparound_failsafe = 0).  The positive path requires reaching the
+-- failsafe XID age and is covered by a separate TAP test under
+-- src/test/modules/xid_wraparound.
+SELECT wraparound_failsafe = 0 AS wraparound_failsafe
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+ wraparound_failsafe 
+---------------------
+ t
+(1 row)
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
@@ -171,13 +182,14 @@ DROP TABLE vacstat_idxdel;
 -- per-database aggregate view: no vacuum errors occurred in this database, and
 -- the vacuums in this database emit WAL (wal_records > 0).
 SELECT errors = 0 AS errors,
+       wraparound_failsafe = 0 AS wraparound_failsafe,
        wal_records > 0 AS wal_records,
        wal_fpi >= 0 AS wal_fpi,
        wal_bytes > 0 AS wal_bytes
   FROM pg_stat_vacuum_database WHERE dbname = current_database();
- errors | wal_records | wal_fpi | wal_bytes 
---------+-------------+---------+-----------
- t      | t           | t       | t
+ errors | wraparound_failsafe | wal_records | wal_fpi | wal_bytes 
+--------+---------------------+-------------+---------+-----------
+ t      | t                   | t           | t       | t
 (1 row)
 
 -- parallel index vacuum: index statistics must be captured for indexes
diff --git a/src/test/regress/sql/vacuum_stats.sql b/src/test/regress/sql/vacuum_stats.sql
index 13491b87f0..8ce60dac77 100644
--- a/src/test/regress/sql/vacuum_stats.sql
+++ b/src/test/regress/sql/vacuum_stats.sql
@@ -80,6 +80,13 @@ SELECT wal_records > 0 AS wal_records,
   FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_fpi';
 DROP TABLE vacstat_fpi;
 
+-- wraparound failsafe.  A normal vacuum does not engage the wraparound
+-- failsafe (wraparound_failsafe = 0).  The positive path requires reaching the
+-- failsafe XID age and is covered by a separate TAP test under
+-- src/test/modules/xid_wraparound.
+SELECT wraparound_failsafe = 0 AS wraparound_failsafe
+  FROM pg_stat_vacuum_tables WHERE relname = 'vacstat_t';
+
 -- per-index view: the primary key index is processed by the same VACUUM.
 -- No btree leaf empties out (interleaved deletions), so pages_deleted = 0,
 -- while every index entry for a removed heap tuple is deleted.
@@ -111,6 +118,7 @@ DROP TABLE vacstat_idxdel;
 -- per-database aggregate view: no vacuum errors occurred in this database, and
 -- the vacuums in this database emit WAL (wal_records > 0).
 SELECT errors = 0 AS errors,
+       wraparound_failsafe = 0 AS wraparound_failsafe,
        wal_records > 0 AS wal_records,
        wal_fpi >= 0 AS wal_fpi,
        wal_bytes > 0 AS wal_bytes
-- 
2.39.5 (Apple Git-154)