From 196f10662b38b9a1938f9d7adac9a47ae8010ef4 Mon Sep 17 00:00:00 2001 From: Alena Rybakina Date: Mon, 20 Jul 2026 16:59:26 +0300 Subject: [PATCH v42 4/9] Count vacuums interrupted by errors in pg_stat_database. Add a vacuum_interrupt_count column to pg_stat_database: the number of times a vacuum in the database was interrupted by an ERROR. An interrupted vacuum never reaches the end-of-vacuum instrumentation or pgstat_report_vacuum(), so this is the only place the event becomes visible; frequently interrupted vacuums silently inflate bloat. The counter is updated from the vacuum error callback. As the transaction is aborting, a pending stats entry might never be flushed, so the shared database entry is updated directly. The new geterrlevel() helper lets the callback tell a genuine ERROR apart from lower-severity reports that merely carry the vacuum error context. --- doc/src/sgml/monitoring.sgml | 9 +++ src/backend/access/heap/vacuumlazy.c | 9 +++ src/backend/catalog/system_views.sql | 1 + src/backend/utils/activity/pgstat_database.c | 25 +++++++ src/backend/utils/adt/pgstatfuncs.c | 3 + src/backend/utils/error/elog.c | 17 +++++ src/include/catalog/pg_proc.dat | 4 ++ src/include/pgstat.h | 4 ++ src/include/utils/elog.h | 1 + src/test/modules/test_misc/meson.build | 1 + .../test_misc/t/015_vacuum_interrupts.pl | 71 +++++++++++++++++++ src/test/regress/expected/rules.out | 1 + 12 files changed, 146 insertions(+) create mode 100644 src/test/modules/test_misc/t/015_vacuum_interrupts.pl diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml index 8b5bff944a3..92c91cb7e3a 100644 --- a/doc/src/sgml/monitoring.sgml +++ b/doc/src/sgml/monitoring.sgml @@ -4132,6 +4132,15 @@ description | Waiting for a newly initialized WAL file to reach durable storage + + + vacuum_interrupt_count bigint + + + Number of times a vacuum in this database was interrupted by an error + + + session_time double precision diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c index e5c8477515c..7e379707464 100644 --- a/src/backend/access/heap/vacuumlazy.c +++ b/src/backend/access/heap/vacuumlazy.c @@ -3828,6 +3828,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 dda36a10d08..7aedb3f4968 100644 --- a/src/backend/catalog/system_views.sql +++ b/src/backend/catalog/system_views.sql @@ -1184,6 +1184,7 @@ CREATE VIEW pg_stat_database AS pg_stat_get_db_total_vacuum_delay_time(D.oid) AS total_vacuum_delay_time, pg_stat_get_db_total_autovacuum_delay_time(D.oid) AS total_autovacuum_delay_time, pg_stat_get_db_vacuum_failsafe_count(D.oid) AS vacuum_failsafe_count, + pg_stat_get_db_vacuum_interrupt_count(D.oid) AS vacuum_interrupt_count, pg_stat_get_db_session_time(D.oid) AS session_time, pg_stat_get_db_active_time(D.oid) AS active_time, pg_stat_get_db_idle_in_transaction_time(D.oid) AS idle_in_transaction_time, diff --git a/src/backend/utils/activity/pgstat_database.c b/src/backend/utils/activity/pgstat_database.c index 3d9f90d1970..e31347b8e70 100644 --- a/src/backend/utils/activity/pgstat_database.c +++ b/src/backend/utils/activity/pgstat_database.c @@ -392,6 +392,31 @@ pgstat_should_report_connstat(void) /* * Find or create a local PgStat_StatDBEntry entry for dboid. */ +/* + * Report that a vacuum in this database was interrupted by an error. + * + * Called from the vacuum error callback while the transaction is aborting, + * so a pending entry might never be flushed; update the shared entry + * directly instead. Shared relations report dboid = InvalidOid, matching + * how their other stats are accounted. + */ +void +pgstat_report_vacuum_error(bool shared) +{ + PgStat_EntryRef *entry_ref; + PgStatShared_Database *sharedent; + Oid dboid = (shared ? InvalidOid : MyDatabaseId); + + if (!pgstat_track_counts) + return; + + entry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_DATABASE, dboid, + InvalidOid, false); + sharedent = (PgStatShared_Database *) entry_ref->shared_stats; + sharedent->stats.vacuum_interrupt_count++; + pgstat_unlock_entry(entry_ref); +} + PgStat_StatDBEntry * pgstat_prep_database_pending(Oid dboid) { diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c index 2aeed43d96e..ac1a9a29105 100644 --- a/src/backend/utils/adt/pgstatfuncs.c +++ b/src/backend/utils/adt/pgstatfuncs.c @@ -1099,6 +1099,9 @@ PG_STAT_GET_DBENTRY_INT64(conflict_tablespace) /* pg_stat_get_db_deadlocks */ PG_STAT_GET_DBENTRY_INT64(deadlocks) +/* pg_stat_get_db_vacuum_interrupt_count */ +PG_STAT_GET_DBENTRY_INT64(vacuum_interrupt_count) + /* pg_stat_get_db_sessions */ PG_STAT_GET_DBENTRY_INT64(sessions) diff --git a/src/backend/utils/error/elog.c b/src/backend/utils/error/elog.c index a6936a0c664..db12b8da252 100644 --- a/src/backend/utils/error/elog.c +++ b/src/backend/utils/error/elog.c @@ -1781,6 +1781,23 @@ geterrcode(void) return edata->sqlerrcode; } +/* + * 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; +} + /* * geterrposition --- return the currently set error position (0 if none) * diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index b975a265a10..78f19294d6a 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -5929,6 +5929,10 @@ proname => 'pg_stat_get_db_blk_write_time', provolatile => 's', proparallel => 'r', prorettype => 'float8', proargtypes => 'oid', prosrc => 'pg_stat_get_db_blk_write_time' }, +{ oid => '8684', descr => 'statistics: number of vacuums interrupted by errors in database', + proname => 'pg_stat_get_db_vacuum_interrupt_count', provolatile => 's', + proparallel => 'r', prorettype => 'int8', proargtypes => 'oid', + prosrc => 'pg_stat_get_db_vacuum_interrupt_count' }, { oid => '8685', descr => 'total vacuum delay time, in milliseconds', proname => 'pg_stat_get_total_vacuum_delay_time', provolatile => 's', proparallel => 'r', prorettype => 'float8', proargtypes => 'oid', diff --git a/src/include/pgstat.h b/src/include/pgstat.h index 6a18582a486..8aa257e58cb 100644 --- a/src/include/pgstat.h +++ b/src/include/pgstat.h @@ -416,6 +416,9 @@ typedef struct PgStat_StatDBEntry */ PgStat_Counter vacuum_failsafe_count; + /* # of vacuums in this database interrupted by errors */ + PgStat_Counter vacuum_interrupt_count; + TimestampTz stat_reset_timestamp; } PgStat_StatDBEntry; @@ -749,6 +752,7 @@ extern void pgstat_report_index_vacuum_time(Relation rel, PgStat_Counter elapsedtime, PgStat_Counter delaytime, bool is_autovacuum); +extern void pgstat_report_vacuum_error(bool shared); extern void pgstat_report_analyze(Relation rel, PgStat_Counter livetuples, PgStat_Counter deadtuples, bool resetcounter, TimestampTz starttime); diff --git a/src/include/utils/elog.h b/src/include/utils/elog.h index 6ae376ba001..7c1369524ef 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 82348b896ba..827b8422013 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_log_statement_max_length.pl', + 't/015_vacuum_interrupts.pl', 't/016_index_vacuum_time.pl', 't/017_vacuum_failsafe.pl', ], diff --git a/src/test/modules/test_misc/t/015_vacuum_interrupts.pl b/src/test/modules/test_misc/t/015_vacuum_interrupts.pl new file mode 100644 index 00000000000..370b7d18a87 --- /dev/null +++ b/src/test/modules/test_misc/t/015_vacuum_interrupts.pl @@ -0,0 +1,71 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test the vacuum_interrupt_count counter of pg_stat_database. +# +# vacuum_interrupt_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 +]); +$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 vacuum_interrupt_count > 0 FROM pg_stat_database WHERE datname = current_database()]), + 't', + 'vacuum_interrupt_count advanced in pg_stat_database'); + +$node->stop; + +done_testing(); diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 40d652e41f0..0dd9b48ab42 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -1925,6 +1925,7 @@ pg_stat_database| SELECT oid AS datid, pg_stat_get_db_total_vacuum_delay_time(oid) AS total_vacuum_delay_time, pg_stat_get_db_total_autovacuum_delay_time(oid) AS total_autovacuum_delay_time, pg_stat_get_db_vacuum_failsafe_count(oid) AS vacuum_failsafe_count, + pg_stat_get_db_vacuum_interrupt_count(oid) AS vacuum_interrupt_count, pg_stat_get_db_session_time(oid) AS session_time, pg_stat_get_db_active_time(oid) AS active_time, pg_stat_get_db_idle_in_transaction_time(oid) AS idle_in_transaction_time, -- 2.39.5 (Apple Git-154)