From 3d16b08296d1cfc623e65c9ba8b61ba47ce3aa6e Mon Sep 17 00:00:00 2001 From: Nathan Bossart Date: Wed, 12 Aug 2026 12:02:21 -0500 Subject: [PATCH v12 8/8] Fix autovacuum's handling of TOAST storage parameters. The previous commit made VACUUM apply a main table's storage parameters to its TOAST table, as CREATE TABLE has long documented. Autovacuum still gets this wrong in two ways. It falls back to the main table's autovacuum parameters only when the TOAST table has no reloptions at all, so setting a single toast.* parameter silently discards the rest. And it never consults the main table for the parameters that only VACUUM reads, since it leaves those for vacuum_rel() to resolve from the TOAST table's own reloptions. To fix, combine the two sets with merge_toast_reloptions() rather than choosing between them, and hand the main table's parameters down to vacuum_rel() the way VACUUM now does. pg_stat_autovacuum_scores uses the combined parameters for TOAST tables as well; it has to collect the main relations' parameters before it can do so, so it now makes a preliminary pass over pg_class. An existing shortcoming that this patch only makes worse is that autovacuum remains oblivious to concurrent storage parameter changes on the main table. That is, the main table's parameters may be captured long before its TOAST table is processed, and a user may very well have altered the settings in the meantime. Fixing that would likely require additional pg_class lookups, and it's not clear if it's worth the trouble. While this is a bug fix, it's too intrusive for back-patching, but the issue seems to have gone unnoticed for a very long time, anyway. --- src/backend/postmaster/autovacuum.c | 149 +++++++++++++----- src/test/modules/test_autovacuum/meson.build | 1 + .../test_autovacuum/t/002_toast_relopts.pl | 69 ++++++++ 3 files changed, 177 insertions(+), 42 deletions(-) create mode 100644 src/test/modules/test_autovacuum/t/002_toast_relopts.pl diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c index 1610c60ec4b..437080a0a41 100644 --- a/src/backend/postmaster/autovacuum.c +++ b/src/backend/postmaster/autovacuum.c @@ -1913,6 +1913,40 @@ TableToProcessComparator(const ListCell *a, const ListCell *b) return (t2->score < t1->score) ? -1 : (t2->score > t1->score) ? 1 : 0; } +/* + * get_effective_relopts + * Fetch the storage parameters that apply to a relation. + * + * This looks up the reloptions for the pg_class relation in "tup". If it is a + * TOAST table, we also merge in any unset reloptions with the main table's + * stored in "toast_map". If the relation neither sets nor inherits any + * reloptions, this function returns NULL. Else, a palloc'd copy of the + * applicable reloptions are returned. + * + * If "tup" refers to a TOAST table and the toast_map has reloptions stored for + * its main relation, we return a pointer to the main table's reloptions via + * *main_opts. Else, main_opts is set to NULL. + */ +static StdRdOptions * +get_effective_relopts(HeapTuple tup, TupleDesc desc, HTAB *toast_map, + StdRdOptions **main_opts) +{ + Form_pg_class classForm = (Form_pg_class) GETSTRUCT(tup); + StdRdOptions *relopts; + av_relation *hentry = NULL; + + /* look up our relopts */ + relopts = (StdRdOptions *) extractRelOptions(tup, desc, NULL); + + /* if we're a TOAST table, look up our parent's relopts, too */ + if (classForm->relkind == RELKIND_TOASTVALUE) + hentry = hash_search(toast_map, &classForm->oid, HASH_FIND, NULL); + *main_opts = hentry ? &hentry->ar_reloptions : NULL; + + /* return the merged reloptions */ + return merge_toast_reloptions(relopts, *main_opts); +} + /* * Process a database table-by-table * @@ -2015,9 +2049,9 @@ do_autovacuum(void) * We do this in two passes: on the first one we collect the list of plain * relations and materialized views, and on the second one we collect * TOAST tables. The reason for doing the second pass is that during it we - * want to use the main relation's pg_class.reloptions entry if the TOAST - * table does not have any, and we cannot obtain it unless we know - * beforehand what's the main table OID. + * want to fill in any storage parameters that the TOAST table does not + * set with the main relation's, and we cannot obtain those values unless + * we know beforehand what's the main table OID. * * We need to check TOAST tables separately because in cases with short, * wide tables there might be proportionally much more activity in the @@ -2128,7 +2162,7 @@ do_autovacuum(void) Form_pg_class classForm = (Form_pg_class) GETSTRUCT(tuple); Oid relid; StdRdOptions *relopts; - bool free_relopts = false; + StdRdOptions *main_relopts; bool dovacuum; bool doanalyze; bool wraparound; @@ -2142,22 +2176,9 @@ do_autovacuum(void) relid = classForm->oid; - /* - * fetch reloptions -- if this toast table does not have them, try the - * main rel - */ - relopts = (StdRdOptions *) extractRelOptions(tuple, pg_class_desc, NULL); - if (relopts) - free_relopts = true; - else - { - av_relation *hentry; - bool found; - - hentry = hash_search(table_toast_map, &relid, HASH_FIND, &found); - if (found) - relopts = &hentry->ar_reloptions; - } + /* fetch reloptions -- merge any unset options from the main rel */ + relopts = get_effective_relopts(tuple, pg_class_desc, table_toast_map, + &main_relopts); relation_needs_vacanalyze(relid, relopts, classForm, effective_multixact_freeze_max_age, @@ -2176,7 +2197,7 @@ do_autovacuum(void) } /* Release stuff to avoid leakage */ - if (free_relopts) + if (relopts) pfree(relopts); } @@ -2782,7 +2803,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map, autovac_table *tab = NULL; bool wraparound; StdRdOptions *relopts; - bool free_relopts = false; + StdRdOptions *main_relopts; AutoVacuumScores scores; /* fetch the relation's relcache entry */ @@ -2792,21 +2813,11 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map, classForm = (Form_pg_class) GETSTRUCT(classTup); /* - * Get the applicable reloptions. If it is a TOAST table, try to get the - * main table reloptions if the toast table itself doesn't have. + * Get the applicable reloptions. If it is a TOAST table, merge in the + * main table's reloptions where they are unset. */ - relopts = (StdRdOptions *) extractRelOptions(classTup, pg_class_desc, NULL); - if (relopts) - free_relopts = true; - else if (classForm->relkind == RELKIND_TOASTVALUE) - { - av_relation *hentry; - bool found; - - hentry = hash_search(table_toast_map, &relid, HASH_FIND, &found); - if (found) - relopts = &hentry->ar_reloptions; - } + relopts = get_effective_relopts(classTup, pg_class_desc, table_toast_map, + &main_relopts); relation_needs_vacanalyze(relid, relopts, classForm, effective_multixact_freeze_max_age, @@ -2891,7 +2902,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map, tab->at_params.log_vacuum_min_duration = log_vacuum_min_duration; tab->at_params.log_analyze_min_duration = log_analyze_min_duration; tab->at_params.toast_parent = InvalidOid; - tab->at_params.main_relopts = NULL; + tab->at_params.main_relopts = main_relopts; /* Determine the number of parallel vacuum workers to use */ tab->at_params.nworkers = 0; @@ -2936,7 +2947,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map, avopts->vacuum_cost_delay >= 0)); } - if (free_relopts) + if (relopts) pfree(relopts); heap_freetuple(classTup); return tab; @@ -2950,8 +2961,8 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map, * being forced because of Xid or multixact wraparound. * * relopts is a pointer to the StdRdOptions options (either for itself in the - * case of a plain table, or for either itself or its parent table in the case - * of a TOAST table), NULL if none. + * case of a plain table, or merged with the main table's for a TOAST table), + * NULL if none. * * A table needs to be vacuumed if the number of dead tuples exceeds a * threshold. This threshold is calculated as @@ -3609,6 +3620,8 @@ pg_stat_get_autovacuum_scores(PG_FUNCTION_ARGS) TableScanDesc scan; HeapTuple tup; ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + HTAB *table_toast_map; + HASHCTL ctl; InitMaterializedSRF(fcinfo, 0); @@ -3617,13 +3630,62 @@ pg_stat_get_autovacuum_scores(PG_FUNCTION_ARGS) recentXid = ReadNextTransactionId(); recentMulti = ReadNextMultiXactId(); - /* scan pg_class */ + /* create hash table for toast <-> main relid mapping */ + ctl.keysize = sizeof(Oid); + ctl.entrysize = sizeof(av_relation); + ctl.hcxt = CurrentMemoryContext; + table_toast_map = hash_create("TOAST to main relid map", + 100, + &ctl, + HASH_ELEM | HASH_BLOBS | HASH_CONTEXT); + + /* + * Do an initial pass over pg_class to collect the main relations' + * reloptions, which we need in order to compute their TOAST tables' + * effective options below. + */ rel = table_open(RelationRelationId, AccessShareLock); scan = table_beginscan_catalog(rel, 0, NULL); while ((tup = heap_getnext(scan, ForwardScanDirection)) != NULL) { Form_pg_class form = (Form_pg_class) GETSTRUCT(tup); StdRdOptions *relopts; + av_relation *hentry; + bool found; + + /* skip ineligible entries */ + if (form->relkind != RELKIND_RELATION && + form->relkind != RELKIND_MATVIEW) + continue; + if (form->relpersistence == RELPERSISTENCE_TEMP) + continue; + if (!OidIsValid(form->reltoastrelid)) + continue; + + relopts = (StdRdOptions *) extractRelOptions(tup, RelationGetDescr(rel), NULL); + if (!relopts) + continue; + + hentry = hash_search(table_toast_map, &form->reltoastrelid, + HASH_ENTER, &found); + Assert(!found); /* rels cannot share a TOAST table */ + + /* hash_search already filled in the key */ + memcpy(&hentry->ar_reloptions, relopts, sizeof(StdRdOptions)); + + pfree(relopts); + } + table_endscan(scan); + + /* + * Now that we have all parents' reloptions, we can generate the results. + */ + scan = table_beginscan_catalog(rel, 0, NULL); + while ((tup = heap_getnext(scan, ForwardScanDirection)) != NULL) + { + Form_pg_class form = (Form_pg_class) GETSTRUCT(tup); + StdRdOptions *relopts; + StdRdOptions *main_relopts; bool dovacuum; bool doanalyze; bool wraparound; @@ -3639,7 +3701,9 @@ pg_stat_get_autovacuum_scores(PG_FUNCTION_ARGS) if (form->relpersistence == RELPERSISTENCE_TEMP) continue; - relopts = (StdRdOptions *) extractRelOptions(tup, RelationGetDescr(rel), NULL); + relopts = get_effective_relopts(tup, RelationGetDescr(rel), + table_toast_map, &main_relopts); + relation_needs_vacanalyze(form->oid, relopts, form, effective_multixact_freeze_max_age, LOG_NEVER, @@ -3663,6 +3727,7 @@ pg_stat_get_autovacuum_scores(PG_FUNCTION_ARGS) } table_endscan(scan); table_close(rel, AccessShareLock); + hash_destroy(table_toast_map); return (Datum) 0; } diff --git a/src/test/modules/test_autovacuum/meson.build b/src/test/modules/test_autovacuum/meson.build index 86e392bc0de..970b9aaae4b 100644 --- a/src/test/modules/test_autovacuum/meson.build +++ b/src/test/modules/test_autovacuum/meson.build @@ -10,6 +10,7 @@ tests += { }, 'tests': [ 't/001_parallel_autovacuum.pl', + 't/002_toast_relopts.pl', ], }, } diff --git a/src/test/modules/test_autovacuum/t/002_toast_relopts.pl b/src/test/modules/test_autovacuum/t/002_toast_relopts.pl new file mode 100644 index 00000000000..ba7596cc82b --- /dev/null +++ b/src/test/modules/test_autovacuum/t/002_toast_relopts.pl @@ -0,0 +1,69 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test autovacuum's handling of TOAST storage parameters + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# Create a test node with autovacuum disabled. +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq{ +autovacuum = off +autovacuum_naptime = '1s' +}); +$node->start; + +# Create TOAST table that is eligible for autovacuum due to inherited relopts. +$node->safe_psql( + 'postgres', qq{ + CREATE TABLE toast_relopts (i int, j text STORAGE EXTERNAL) WITH + (autovacuum_enabled = false, toast.autovacuum_enabled = true, + autovacuum_vacuum_threshold = 1, + autovacuum_vacuum_scale_factor = 0, + autovacuum_vacuum_insert_threshold = 1, + autovacuum_vacuum_insert_scale_factor = 0, + vacuum_truncate = false); + INSERT INTO toast_relopts VALUES (1, repeat('a', 10000)), (2, repeat('b', 10000)); + SELECT pg_stat_force_next_flush(); +}); + +# Get TOAST table's OID for following commands. +my $toast = $node->safe_psql('postgres', + "SELECT reltoastrelid::regclass FROM pg_class WHERE oid = 'toast_relopts'::regclass" +); + +# Verify scores view used inherited insert threshold. +is( $node->safe_psql( + 'postgres', qq{ + SELECT vacuum_insert_score > 1 FROM pg_stat_autovacuum_scores + WHERE relid = '$toast'::regclass +}), + 't', + 'inherited insert threshold in pg_stat_autovacuum_scores'); + +# Delete all rows so that we can verify inherited vacuum_truncate takes effect. +$node->safe_psql('postgres', 'DELETE FROM toast_relopts'); + +# Enable autovacuum. +$node->append_conf('postgresql.conf', 'autovacuum = on'); +$node->reload; + +# Wait until autovacuum processes the table. +ok( $node->poll_query_until( + 'postgres', qq{ + SELECT last_autovacuum IS NOT NULL FROM pg_stat_all_tables + WHERE relid = '$toast'::regclass +}), + 'autovacuum of a TOAST table with inherited thresholds'); + +# Verify autovacuum didn't truncate the table. +is($node->safe_psql('postgres', "SELECT pg_relation_size('$toast') > 0"), + 't', 'inherited vacuum_truncate'); + +$node->stop; +done_testing(); -- 2.55.0