From ada6537d99e5bed2aa1a5d1d47c6daa4d7f7ac16 Mon Sep 17 00:00:00 2001
From: Nathan Bossart <nathan@postgresql.org>
Date: Wed, 12 Aug 2026 12:02:21 -0500
Subject: [PATCH v13 5/5] 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           | 148 +++++++++++++-----
 src/test/modules/test_autovacuum/meson.build  |   1 +
 .../test_autovacuum/t/002_toast_relopts.pl    |  69 ++++++++
 3 files changed, 176 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 0b1fcec7b29..be239132572 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
@@ -2130,7 +2164,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;
@@ -2144,22 +2178,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 ? &relopts->autovacuum : NULL,
@@ -2180,7 +2201,7 @@ do_autovacuum(void)
 		}
 
 		/* Release stuff to avoid leakage */
-		if (free_relopts)
+		if (relopts)
 			pfree(relopts);
 	}
 
@@ -2787,7 +2808,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 	bool		wraparound;
 	AutoVacOpts *avopts;
 	StdRdOptions *relopts;
-	bool		free_relopts = false;
+	StdRdOptions *main_relopts;
 	AutoVacuumScores scores;
 
 	/* fetch the relation's relcache entry */
@@ -2797,21 +2818,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);
 
 	avopts = relopts ? &relopts->autovacuum : NULL;
 
@@ -2897,7 +2908,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;
@@ -2942,7 +2953,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;
@@ -2956,8 +2967,8 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map,
  * being forced because of Xid or multixact wraparound.
  *
  * relopts is a pointer to the AutoVacOpts 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
@@ -3614,6 +3625,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);
 
@@ -3622,13 +3635,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;
@@ -3644,7 +3706,8 @@ 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 ? &relopts->autovacuum : NULL,
 								  form,
@@ -3670,6 +3733,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

