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, ¶ms->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)