From fcc44f6d00b721ac908cc147b0b931cbff1bec69 Mon Sep 17 00:00:00 2001 From: Bharath Rupireddy Date: Fri, 16 Oct 2026 15:59:28 +0000 Subject: [PATCH v1] Check XID wraparound failsafe between indexes in parallel vacuum In a parallel vacuum the XID wraparound failsafe is checked only after the parallel workers have vacuumed all the indexes, and cost-based delays are applied until then. This is unlike non-parallel vacuum. With large indexes, this can delay advancing relfrozenxid and relminmxid, bringing the database closer to XID wraparound and eventually to downtime for writes. With parallel autovacuum in PG19 and its default cost limit and delay, the advancement can be delayed even further. Fix this by having parallel vacuum check the wraparound failsafe after each index, as non-parallel vacuum does. The table's cutoffs are shared with the parallel workers, and each parallel worker and the leader check the wraparound failsafe after each index they vacuum and set a shared flag if the failsafe has triggered. They check this shared flag at every vacuum delay point and, once it is set, ignore the cost-based delays, so that the index being vacuumed finishes sooner, and they skip the remaining indexes, so that relfrozenxid and relminmxid can be advanced sooner to avoid XID wraparound. Author: Bharath Rupireddy Discussion: https://postgr.es/m/ --- src/backend/access/heap/vacuumlazy.c | 13 ++- src/backend/commands/vacuum.c | 16 ++++ src/backend/commands/vacuumparallel.c | 50 +++++++++- src/include/commands/vacuum.h | 7 +- src/test/modules/test_autovacuum/Makefile | 3 +- src/test/modules/test_autovacuum/meson.build | 1 + .../t/003_parallel_vacuum_failsafe.pl | 96 +++++++++++++++++++ 7 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 src/test/modules/test_autovacuum/t/003_parallel_vacuum_failsafe.pl diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c index 3e2fc8f0b24..bf63330a136 100644 --- a/src/backend/access/heap/vacuumlazy.c +++ b/src/backend/access/heap/vacuumlazy.c @@ -2570,8 +2570,7 @@ lazy_vacuum_all_indexes(LVRelState *vacrel) &(vacrel->worker_usage.vacuum)); /* - * Do a postcheck to consider applying wraparound failsafe now. Note - * that parallel VACUUM only gets the precheck and this postcheck. + * Do a postcheck to consider applying wraparound failsafe now. */ if (lazy_check_wraparound_failsafe(vacrel)) allindexes = false; @@ -2897,6 +2896,9 @@ lazy_vacuum_heap_page(LVRelState *vacrel, BlockNumber blkno, Buffer buffer, * up when the failsafe triggers. VACUUM stops applying any cost-based delay * that it started out with. * + * In a parallel vacuum, this also triggers the failsafe if a worker has + * detected it, without rechecking the age here. + * * Returns true when failsafe has been triggered. */ static bool @@ -2906,7 +2908,9 @@ lazy_check_wraparound_failsafe(LVRelState *vacrel) if (VacuumFailsafeActive) return true; - if (unlikely(vacuum_xid_failsafe_check(&vacrel->cutoffs))) + if (unlikely((ParallelVacuumIsActive(vacrel) && + parallel_vacuum_failsafe_triggered(vacrel->pvs)) || + vacuum_xid_failsafe_check(&vacrel->cutoffs))) { const int progress_index[] = { PROGRESS_VACUUM_INDEXES_TOTAL, @@ -3496,7 +3500,8 @@ dead_items_alloc(LVRelState *vacrel, int nworkers) vacrel->nindexes, nworkers, vac_work_mem, vacrel->verbose ? INFO : DEBUG2, - vacrel->bstrategy); + vacrel->bstrategy, + &vacrel->cutoffs); /* * If parallel mode started, dead_items and dead_items_info spaces are diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c index c7595d59bb2..b18d94433dc 100644 --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -119,6 +119,9 @@ pg_atomic_uint32 *VacuumSharedCostBalance = NULL; pg_atomic_uint32 *VacuumActiveNWorkers = NULL; int VacuumCostBalanceLocal = 0; +/* Shared wraparound failsafe flag, set while vacuuming indexes in parallel */ +pg_atomic_uint32 *VacuumSharedFailsafe = NULL; + /* non-export function prototypes */ static List *expand_vacuum_rel(VacuumRelation *vrel, MemoryContext vac_context, int options); @@ -687,6 +690,7 @@ vacuum(List *relations, const VacuumParams *params, BufferAccessStrategy bstrate VacuumCostBalanceLocal = 0; VacuumSharedCostBalance = NULL; VacuumActiveNWorkers = NULL; + VacuumSharedFailsafe = NULL; } PG_END_TRY(); @@ -2531,6 +2535,18 @@ vacuum_delay_point(bool is_analyze) parallel_vacuum_propagate_shared_delay_params(); } + /* + * Stop the delay if a process of a parallel vacuum hit the wraparound + * failsafe, so that the index this process is vacuuming finishes sooner. + * This comes after the reload above, which can turn the delay back on. + */ + if (VacuumSharedFailsafe != NULL && + pg_atomic_read_u32(VacuumSharedFailsafe) != 0) + { + VacuumCostActive = false; + VacuumCostBalance = 0; + } + /* * If we disabled cost-based delays after reloading the config file, * return. diff --git a/src/backend/commands/vacuumparallel.c b/src/backend/commands/vacuumparallel.c index c29f62c5a99..ee9990d1a63 100644 --- a/src/backend/commands/vacuumparallel.c +++ b/src/backend/commands/vacuumparallel.c @@ -151,6 +151,10 @@ typedef struct PVShared /* Counter for vacuuming and cleanup */ pg_atomic_uint32 idx; + /* For the failsafe check between indexes, the flag never goes back to 0 */ + struct VacuumCutoffs cutoffs; + pg_atomic_uint32 failsafe_triggered; + /* DSA handle where the TidStore lives */ dsa_handle dead_items_dsa_handle; @@ -302,12 +306,15 @@ static void parallel_vacuum_dsm_detach(dsm_segment *seg, Datum arg); * Try to enter parallel mode and create a parallel context. Then initialize * shared memory state. * + * cutoffs are shared with the workers for the failsafe check between indexes. + * * On success, return parallel vacuum state. Otherwise return NULL. */ ParallelVacuumState * parallel_vacuum_init(Relation rel, Relation *indrels, int nindexes, int nrequested_workers, int vac_work_mem, - int elevel, BufferAccessStrategy bstrategy) + int elevel, BufferAccessStrategy bstrategy, + const struct VacuumCutoffs *cutoffs) { ParallelVacuumState *pvs; ParallelContext *pcxt; @@ -457,6 +464,9 @@ parallel_vacuum_init(Relation rel, Relation *indrels, int nindexes, pg_atomic_init_u32(&(shared->active_nworkers), 0); pg_atomic_init_u32(&(shared->idx), 0); + memcpy(&(shared->cutoffs), cutoffs, sizeof(struct VacuumCutoffs)); + pg_atomic_init_u32(&(shared->failsafe_triggered), 0); + shared->is_autovacuum = AmAutoVacuumWorkerProcess(); /* @@ -595,6 +605,13 @@ parallel_vacuum_reset_dead_items(ParallelVacuumState *pvs) dead_items_info->num_items = 0; } +/* Has one of the processes triggered the wraparound failsafe? */ +bool +parallel_vacuum_failsafe_triggered(ParallelVacuumState *pvs) +{ + return pg_atomic_read_u32(&(pvs->shared->failsafe_triggered)) != 0; +} + /* * Do parallel index bulk-deletion with parallel workers. */ @@ -858,6 +875,7 @@ parallel_vacuum_process_all_indexes(ParallelVacuumState *pvs, int num_index_scan { int nworkers; PVIndVacStatus new_status; + bool failsafe; Assert(!IsParallelWorker()); @@ -952,6 +970,9 @@ parallel_vacuum_process_all_indexes(ParallelVacuumState *pvs, int num_index_scan VacuumSharedCostBalance = &(pvs->shared->cost_balance); VacuumActiveNWorkers = &(pvs->shared->active_nworkers); + /* The leader's own delay stops via this flag too */ + VacuumSharedFailsafe = &(pvs->shared->failsafe_triggered); + /* Update the statistics, if we asked to */ if (wstats != NULL) wstats->nlaunched += pvs->pcxt->nworkers_launched; @@ -995,13 +1016,18 @@ parallel_vacuum_process_all_indexes(ParallelVacuumState *pvs, int num_index_scan /* * Reset all index status back to initial (while checking that we have - * vacuumed all indexes). + * vacuumed all indexes). Skipped indexes have no status of their own, so + * once the failsafe has triggered we assume any index left unfinished was + * skipped by it. */ + failsafe = parallel_vacuum_failsafe_triggered(pvs); for (int i = 0; i < pvs->nindexes; i++) { PVIndStats *indstats = &(pvs->indstats[i]); - if (indstats->status != PARALLEL_INDVAC_STATUS_COMPLETED) + if (indstats->status != PARALLEL_INDVAC_STATUS_COMPLETED && + !(failsafe && + indstats->status == PARALLEL_INDVAC_STATUS_NEED_BULKDELETE)) elog(ERROR, "parallel index vacuum on index \"%s\" is not completed", RelationGetRelationName(pvs->indrels[i])); @@ -1016,6 +1042,7 @@ parallel_vacuum_process_all_indexes(ParallelVacuumState *pvs, int num_index_scan VacuumCostBalance = pg_atomic_read_u32(VacuumSharedCostBalance); VacuumSharedCostBalance = NULL; VacuumActiveNWorkers = NULL; + VacuumSharedFailsafe = NULL; } } @@ -1133,6 +1160,17 @@ parallel_vacuum_process_one_index(ParallelVacuumState *pvs, Relation indrel, }; const int64 reset_val[] = {(int64) InvalidOid, 0, 0}; + /* Don't start another index once the failsafe has triggered */ + if (indstats->status == PARALLEL_INDVAC_STATUS_NEED_BULKDELETE && + parallel_vacuum_failsafe_triggered(pvs)) + { + /* Show failsafe mode in the leader's row, workers just exit */ + if (!IsParallelWorker()) + pgstat_progress_update_param(PROGRESS_VACUUM_MODE, + PROGRESS_VACUUM_MODE_FAILSAFE); + return; + } + /* * Update the pointer to the corresponding bulk-deletion result if someone * has already updated it @@ -1166,6 +1204,11 @@ parallel_vacuum_process_one_index(ParallelVacuumState *pvs, Relation indrel, case PARALLEL_INDVAC_STATUS_NEED_BULKDELETE: istat_res = vac_bulkdel_one_index(&ivinfo, istat, pvs->dead_items, &pvs->shared->dead_items_info); + + /* Check the failsafe as a non-parallel vacuum does */ + INJECTION_POINT("parallel-vacuum-failsafe-check", NULL); + if (vacuum_xid_failsafe_check(&(pvs->shared->cutoffs))) + pg_atomic_write_u32(&(pvs->shared->failsafe_triggered), 1); break; case PARALLEL_INDVAC_STATUS_NEED_CLEANUP: istat_res = vac_cleanup_one_index(&ivinfo, istat); @@ -1356,6 +1399,7 @@ parallel_vacuum_main(dsm_segment *seg, shm_toc *toc) VacuumCostBalanceLocal = 0; VacuumSharedCostBalance = &(shared->cost_balance); VacuumActiveNWorkers = &(shared->active_nworkers); + VacuumSharedFailsafe = &(shared->failsafe_triggered); /* Set parallel vacuum state */ pvs.indrels = indrels; diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h index 89fa1fcd261..972ff1fd3db 100644 --- a/src/include/commands/vacuum.h +++ b/src/include/commands/vacuum.h @@ -362,6 +362,9 @@ extern PGDLLIMPORT pg_atomic_uint32 *VacuumSharedCostBalance; extern PGDLLIMPORT pg_atomic_uint32 *VacuumActiveNWorkers; extern PGDLLIMPORT int VacuumCostBalanceLocal; +/* Shared wraparound failsafe flag, set while vacuuming indexes in parallel */ +extern PGDLLIMPORT pg_atomic_uint32 *VacuumSharedFailsafe; + extern PGDLLIMPORT bool VacuumFailsafeActive; extern PGDLLIMPORT double vacuum_cost_delay; extern PGDLLIMPORT int vacuum_cost_limit; @@ -416,11 +419,13 @@ extern void VacuumUpdateCosts(void); extern ParallelVacuumState *parallel_vacuum_init(Relation rel, Relation *indrels, int nindexes, int nrequested_workers, int vac_work_mem, int elevel, - BufferAccessStrategy bstrategy); + BufferAccessStrategy bstrategy, + const struct VacuumCutoffs *cutoffs); extern void parallel_vacuum_end(ParallelVacuumState *pvs, IndexBulkDeleteResult **istats); extern TidStore *parallel_vacuum_get_dead_items(ParallelVacuumState *pvs, VacDeadItemsInfo **dead_items_info_p); extern void parallel_vacuum_reset_dead_items(ParallelVacuumState *pvs); +extern bool parallel_vacuum_failsafe_triggered(ParallelVacuumState *pvs); extern void parallel_vacuum_bulkdel_all_indexes(ParallelVacuumState *pvs, long num_table_tuples, int num_index_scans, diff --git a/src/test/modules/test_autovacuum/Makefile b/src/test/modules/test_autovacuum/Makefile index 15e83010c1c..f5077de515c 100644 --- a/src/test/modules/test_autovacuum/Makefile +++ b/src/test/modules/test_autovacuum/Makefile @@ -4,7 +4,8 @@ PGFILEDESC = "test_autovacuum - test code for autovacuum" TAP_TESTS = 1 -EXTRA_INSTALL = src/test/modules/injection_points +EXTRA_INSTALL = src/test/modules/injection_points \ + src/test/modules/xid_wraparound export enable_injection_points diff --git a/src/test/modules/test_autovacuum/meson.build b/src/test/modules/test_autovacuum/meson.build index 970b9aaae4b..f1a4123d50c 100644 --- a/src/test/modules/test_autovacuum/meson.build +++ b/src/test/modules/test_autovacuum/meson.build @@ -11,6 +11,7 @@ tests += { 'tests': [ 't/001_parallel_autovacuum.pl', 't/002_toast_relopts.pl', + 't/003_parallel_vacuum_failsafe.pl', ], }, } diff --git a/src/test/modules/test_autovacuum/t/003_parallel_vacuum_failsafe.pl b/src/test/modules/test_autovacuum/t/003_parallel_vacuum_failsafe.pl new file mode 100644 index 00000000000..f49b2da2b05 --- /dev/null +++ b/src/test/modules/test_autovacuum/t/003_parallel_vacuum_failsafe.pl @@ -0,0 +1,96 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Parallel vacuum checks the wraparound failsafe after each index, and skips +# the indexes it has not started once the failsafe has triggered. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'Injection points not supported by this build' + unless $ENV{enable_injection_points} eq 'yes'; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; + +# Only the test table meets its thresholds and one worker runs at a time, so +# no other table's worker gets held at the point. The failsafe age is never +# less than autovacuum_freeze_max_age * 1.05, so 105000 XIDs here. +$node->append_conf( + 'postgresql.conf', qq{ +autovacuum_naptime = '1s' +autovacuum_max_workers = 1 +autovacuum_max_parallel_workers = 1 +autovacuum_vacuum_threshold = 100000 +autovacuum_vacuum_insert_threshold = -1 +autovacuum_freeze_max_age = 100000 +vacuum_failsafe_age = 100000 +min_parallel_index_scan_size = 0 +}); +$node->start; + +# The modules may be absent under installcheck. +if ( !$node->check_extension('injection_points') + || !$node->check_extension('xid_wraparound')) +{ + plan skip_all => 'Required test extensions are not installed'; +} + +# The leader and its one parallel worker take one index each of the four, +# then wait at the injection point. +$node->safe_psql( + 'postgres', qq{ + CREATE EXTENSION injection_points; + CREATE EXTENSION xid_wraparound; + CREATE TABLE test_failsafe (col int) + WITH (autovacuum_enabled = false, + autovacuum_parallel_workers = 1, + autovacuum_vacuum_threshold = 1, + autovacuum_vacuum_scale_factor = 0, + log_autovacuum_min_duration = 0); + INSERT INTO test_failsafe SELECT generate_series(1, 100); + CREATE INDEX idx_1 ON test_failsafe (col); + CREATE INDEX idx_2 ON test_failsafe (col); + CREATE INDEX idx_3 ON test_failsafe (col); + CREATE INDEX idx_4 ON test_failsafe (col); + DELETE FROM test_failsafe; + SELECT injection_points_attach('parallel-vacuum-failsafe-check', 'wait'); +}); + +my $log_offset = -s $node->logfile; +$node->safe_psql('postgres', + 'ALTER TABLE test_failsafe SET (autovacuum_enabled = true)'); +$node->wait_for_event('autovacuum worker', 'parallel-vacuum-failsafe-check'); +$node->wait_for_event('parallel worker', 'parallel-vacuum-failsafe-check'); + +$node->safe_psql('postgres', 'SELECT consume_xids(150000)'); +$node->safe_psql('postgres', + "SELECT injection_points_detach('parallel-vacuum-failsafe-check')"); + +# Release the two processes, a wakeup reaches only the first one registered +foreach my $left (1, 0) +{ + $node->safe_psql('postgres', + "SELECT injection_points_wakeup('parallel-vacuum-failsafe-check')"); + $node->poll_query_until('postgres', + "SELECT count(*) = $left FROM pg_stat_activity + WHERE wait_event = 'parallel-vacuum-failsafe-check'") + or die "timed out releasing a held vacuum process"; +} + +# The report lists only the indexes that were vacuumed +$node->wait_for_log( + qr/automatic vacuum of table "postgres\.public\.test_failsafe"/, + $log_offset); +my @vacuumed = + slurp_file($node->logfile, $log_offset) =~ /index "(idx_\d)":/g; +note("indexes vacuumed: @vacuumed"); + +is(scalar(@vacuumed), 2, + 'remaining indexes are skipped once the failsafe triggers'); + +$node->stop; +done_testing(); -- 2.47.3