From c7f6f3ddd622e9936a64aaa26de4ffadf8506405 Mon Sep 17 00:00:00 2001
From: Nathan Bossart <nathan@postgresql.org>
Date: Tue, 9 Jun 2026 11:53:12 -0500
Subject: [PATCH v5 4/4] Fix VACUUM and autovacuum handling of TOAST storage
 parameters.

Per the documentation for CREATE TABLE:

    If a table parameter value is set and the equivalent toast.
    parameter is not, the TOAST table will use the table's
    parameter value.

Unfortunately, current reality does not match this description.
Neither VACUUM nor autovacuum consults the main table's
non-autovacuum storage parameters, and autovacuum only consults the
main table's autovacuum-related storage parameters if the TOAST
table lacks any.  One silver lining is that all currently-supported
TOAST storage parameters are related to vacuum, whose code paths
already access the main table's parameters.  This means that our
solution needn't involve more lookups; we just need to propagate
them correctly.

To fix, this commit teaches autovacuum to combine the TOAST storage
parameters with the main table's (with the toast.* ones winning if
both are set), and it teaches VACUUM to send down the main table's
parameters when recursing to a TOAST table.  This doesn't fix
VACUUM against a TOAST table directly (e.g., VACUUM
pg_toast.pg_toast_5432), but that's probably okay because it's not
the main supported way to vacuum a TOAST table (see VACUUM's
PROCESS_MAIN and PROCESS_TOAST options).

An existing shortcoming that this patch only makes worse is that
autovacuum/VACUUM remain 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/commands/vacuum.c                 |  36 +++-
 src/backend/postmaster/autovacuum.c           | 187 ++++++++++++++++--
 src/include/commands/vacuum.h                 |   8 +
 src/include/utils/rel.h                       |   3 +
 .../injection_points/expected/vacuum.out      |  11 ++
 .../modules/injection_points/sql/vacuum.sql   |   8 +
 6 files changed, 234 insertions(+), 19 deletions(-)

diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 4ee1f913b64..a2f77b349b2 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -187,6 +187,9 @@ ExecVacuum(ParseState *pstate, VacuumStmt *vacstmt, bool isTopLevel)
 
 	/* Will be set later if we recurse to a TOAST table. */
 	params.toast_parent = InvalidOid;
+	params.main_index_cleanup = VACOPTVALUE_UNSPECIFIED;
+	params.main_truncate = VACOPTVALUE_UNSPECIFIED;
+	params.main_max_eager_freeze_failure_rate = -1.0;
 
 	/*
 	 * Set this to an invalid value so it is clear whether or not a
@@ -2184,7 +2187,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 
 	/*
 	 * Set index_cleanup option based on index_cleanup reloption if it wasn't
-	 * specified in VACUUM command, or when running in an autovacuum worker
+	 * specified in VACUUM command, or when running in an autovacuum worker. A
+	 * TOAST table with no setting of its own inherits the main table's value.
 	 */
 	if (params.index_cleanup == VACOPTVALUE_UNSPECIFIED)
 	{
@@ -2200,15 +2204,21 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 		{
 			case STDRD_OPTION_VACUUM_INDEX_CLEANUP_ON:
 				params.index_cleanup = VACOPTVALUE_ENABLED;
+				toast_vacuum_params.main_index_cleanup = VACOPTVALUE_ENABLED;
 				break;
 			case STDRD_OPTION_VACUUM_INDEX_CLEANUP_OFF:
 				params.index_cleanup = VACOPTVALUE_DISABLED;
+				toast_vacuum_params.main_index_cleanup = VACOPTVALUE_DISABLED;
 				break;
 			case STDRD_OPTION_VACUUM_INDEX_CLEANUP_AUTO:
 				params.index_cleanup = VACOPTVALUE_AUTO;
+				toast_vacuum_params.main_index_cleanup = VACOPTVALUE_AUTO;
 				break;
 			case STDRD_OPTION_VACUUM_INDEX_CLEANUP_NOT_SET:
-				params.index_cleanup = VACOPTVALUE_AUTO;
+				if (params.main_index_cleanup != VACOPTVALUE_UNSPECIFIED)
+					params.index_cleanup = params.main_index_cleanup;
+				else
+					params.index_cleanup = VACOPTVALUE_AUTO;
 				break;
 		}
 	}
@@ -2224,16 +2234,26 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 
 	/*
 	 * Check if the vacuum_max_eager_freeze_failure_rate table storage
-	 * parameter was specified. This overrides the GUC value.
+	 * parameter was specified. This overrides the GUC value.  A TOAST table
+	 * with no setting of its own inherits the main table's value.
 	 */
 	if (rel->rd_options != NULL &&
 		((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate >= 0)
+	{
 		params.max_eager_freeze_failure_rate =
 			((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate;
+		toast_vacuum_params.main_max_eager_freeze_failure_rate =
+			((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate;
+	}
+	else if (params.main_max_eager_freeze_failure_rate >= 0.0)
+		params.max_eager_freeze_failure_rate =
+			params.main_max_eager_freeze_failure_rate;
 
 	/*
 	 * Set truncate option based on truncate reloption or GUC if it wasn't
-	 * specified in VACUUM command, or when running in an autovacuum worker
+	 * specified in VACUUM command, or when running in an autovacuum worker. A
+	 * TOAST table with no setting of its own inherits the main table's value
+	 * before falling back to the GUC.
 	 */
 	if (params.truncate == VACOPTVALUE_UNSPECIFIED)
 	{
@@ -2242,10 +2262,18 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 		if (opts && opts->vacuum_truncate != PG_TERNARY_UNSET)
 		{
 			if (opts->vacuum_truncate == PG_TERNARY_TRUE)
+			{
 				params.truncate = VACOPTVALUE_ENABLED;
+				toast_vacuum_params.main_truncate = VACOPTVALUE_ENABLED;
+			}
 			else
+			{
 				params.truncate = VACOPTVALUE_DISABLED;
+				toast_vacuum_params.main_truncate = VACOPTVALUE_DISABLED;
+			}
 		}
+		else if (params.main_truncate != VACOPTVALUE_UNSPECIFIED)
+			params.truncate = params.main_truncate;
 		else if (vacuum_truncate)
 			params.truncate = VACOPTVALUE_ENABLED;
 		else
diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c
index aa2aca8fc4b..50c7f3d171d 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -396,6 +396,8 @@ static void autovac_report_workitem(AutoVacuumWorkItem *workitem,
 static void avl_sigusr2_handler(SIGNAL_ARGS);
 static bool av_worker_available(void);
 static void check_av_worker_gucs(void);
+static StdRdOptions *merge_autovac_opts(StdRdOptions *toast_opts,
+										StdRdOptions *main_opts);
 
 
 
@@ -2142,6 +2144,8 @@ do_autovacuum(void)
 		bool		doanalyze;
 		bool		wraparound;
 		AutoVacuumScores scores;
+		av_relation *hentry;
+		bool		found;
 
 		/*
 		 * We cannot safely process other backends' temp tables, so skip 'em.
@@ -2152,21 +2156,19 @@ do_autovacuum(void)
 		relid = classForm->oid;
 
 		/*
-		 * fetch reloptions -- if this toast table does not have them, try the
-		 * main rel
+		 * fetch reloptions -- merge any unset options from the main rel
+		 *
+		 * Note that we don't bother merging the non-autovacuum relopts here
+		 * because they do not impact our choice of whether to process the
+		 * table.
 		 */
 		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 && hentry->ar_hasrelopts)
-				relopts = &hentry->ar_reloptions;
-		}
+		hentry = hash_search(table_toast_map, &relid, HASH_FIND, &found);
+		if (found && hentry->ar_hasrelopts)
+			relopts = merge_autovac_opts(relopts, &hentry->ar_reloptions);
 
 		relation_needs_vacanalyze(relid, relopts, classForm,
 								  effective_multixact_freeze_max_age,
@@ -2791,6 +2793,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 	autovac_table *tab = NULL;
 	bool		wraparound;
 	StdRdOptions *relopts;
+	StdRdOptions *main_relopts = NULL;
 	bool		free_relopts = false;
 	AutoVacuumScores scores;
 
@@ -2801,21 +2804,25 @@ 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 &&
-			 table_toast_map != NULL)
+
+	if (classForm->relkind == RELKIND_TOASTVALUE &&
+		table_toast_map != NULL)
 	{
 		av_relation *hentry;
 		bool		found;
 
 		hentry = hash_search(table_toast_map, &relid, HASH_FIND, &found);
 		if (found && hentry->ar_hasrelopts)
-			relopts = &hentry->ar_reloptions;
+		{
+			main_relopts = &hentry->ar_reloptions;
+			relopts = merge_autovac_opts(relopts, main_relopts);
+		}
 	}
 
 	relation_needs_vacanalyze(relid, relopts, classForm,
@@ -2902,6 +2909,48 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 		tab->at_params.log_analyze_min_duration = log_analyze_min_duration;
 		tab->at_params.toast_parent = InvalidOid;
 
+		/*
+		 * For TOAST tables, provide fallbacks for options that are not part
+		 * of AutoVacOpts (and thus are not handled by merge_autovac_opts()).
+		 */
+		tab->at_params.main_index_cleanup = VACOPTVALUE_UNSPECIFIED;
+		tab->at_params.main_truncate = VACOPTVALUE_UNSPECIFIED;
+		tab->at_params.main_max_eager_freeze_failure_rate = -1.0;
+
+		if (main_relopts != NULL)
+		{
+			switch (main_relopts->vacuum_index_cleanup)
+			{
+				case STDRD_OPTION_VACUUM_INDEX_CLEANUP_ON:
+					tab->at_params.main_index_cleanup = VACOPTVALUE_ENABLED;
+					break;
+				case STDRD_OPTION_VACUUM_INDEX_CLEANUP_OFF:
+					tab->at_params.main_index_cleanup = VACOPTVALUE_DISABLED;
+					break;
+				case STDRD_OPTION_VACUUM_INDEX_CLEANUP_AUTO:
+					tab->at_params.main_index_cleanup = VACOPTVALUE_AUTO;
+					break;
+				case STDRD_OPTION_VACUUM_INDEX_CLEANUP_NOT_SET:
+					break;
+			}
+
+			switch (main_relopts->vacuum_truncate)
+			{
+				case PG_TERNARY_TRUE:
+					tab->at_params.main_truncate = VACOPTVALUE_ENABLED;
+					break;
+				case PG_TERNARY_FALSE:
+					tab->at_params.main_truncate = VACOPTVALUE_DISABLED;
+					break;
+				case PG_TERNARY_UNSET:
+					break;
+			}
+
+			if (main_relopts->vacuum_max_eager_freeze_failure_rate >= 0.0)
+				tab->at_params.main_max_eager_freeze_failure_rate =
+					main_relopts->vacuum_max_eager_freeze_failure_rate;
+		}
+
 		/* Determine the number of parallel vacuum workers to use */
 		tab->at_params.nworkers = 0;
 		if (avopts)
@@ -3670,3 +3719,111 @@ pg_stat_get_autovacuum_scores(PG_FUNCTION_ARGS)
 
 	return (Datum) 0;
 }
+
+/*
+ * Combine a TOAST table's autovacuum options with the main table's.  Any
+ * option the TOAST table leaves unset is taken from the main table's options.
+ * Either argument may be NULL.  If both are NULL, NULL is returned.
+ * Otherwise, the options to use are returned.
+ *
+ * NB: This function destructively modifies toast_opts!
+ */
+static StdRdOptions *
+merge_autovac_opts(StdRdOptions *toast_opts, StdRdOptions *main_opts)
+{
+	/* ternary fields */
+	static const int ternary_offsets[] = {
+		offsetof(AutoVacOpts, enabled),
+	};
+
+	/* integer fields whose unset sentinel is -1 */
+	static const int int_offsets_1[] = {
+		offsetof(AutoVacOpts, autovacuum_parallel_workers),
+		offsetof(AutoVacOpts, vacuum_threshold),
+		offsetof(AutoVacOpts, analyze_threshold),
+		offsetof(AutoVacOpts, vacuum_cost_limit),
+		offsetof(AutoVacOpts, freeze_min_age),
+		offsetof(AutoVacOpts, freeze_max_age),
+		offsetof(AutoVacOpts, freeze_table_age),
+		offsetof(AutoVacOpts, multixact_freeze_min_age),
+		offsetof(AutoVacOpts, multixact_freeze_max_age),
+		offsetof(AutoVacOpts, multixact_freeze_table_age),
+		offsetof(AutoVacOpts, log_vacuum_min_duration),
+		offsetof(AutoVacOpts, log_analyze_min_duration),
+	};
+
+	/* integer fields whose unset sentinel is -2 */
+	static const int int_offsets_2[] = {
+		offsetof(AutoVacOpts, vacuum_max_threshold),
+		offsetof(AutoVacOpts, vacuum_ins_threshold),
+	};
+
+	/* float fields */
+	static const int float_offsets[] = {
+		offsetof(AutoVacOpts, vacuum_cost_delay),
+		offsetof(AutoVacOpts, vacuum_scale_factor),
+		offsetof(AutoVacOpts, vacuum_ins_scale_factor),
+		offsetof(AutoVacOpts, analyze_scale_factor),
+	};
+
+	AutoVacOpts *toast_avopts;
+	AutoVacOpts *main_avopts;
+
+	if (toast_opts == NULL)
+		return main_opts;
+	if (main_opts == NULL)
+		return toast_opts;
+
+	toast_avopts = &toast_opts->autovacuum;
+	main_avopts = &main_opts->autovacuum;
+
+	for (int i = 0; i < lengthof(ternary_offsets); i++)
+	{
+		pg_ternary *toast_opt;
+		pg_ternary *main_opt;
+
+		toast_opt = (pg_ternary *) ((char *) toast_avopts + ternary_offsets[i]);
+		main_opt = (pg_ternary *) ((char *) main_avopts + ternary_offsets[i]);
+
+		if (*toast_opt == PG_TERNARY_UNSET)
+			*toast_opt = *main_opt;
+	}
+
+	for (int i = 0; i < lengthof(int_offsets_1); i++)
+	{
+		int		   *toast_opt;
+		int		   *main_opt;
+
+		toast_opt = (int *) ((char *) toast_avopts + int_offsets_1[i]);
+		main_opt = (int *) ((char *) main_avopts + int_offsets_1[i]);
+
+		if (*toast_opt == -1)
+			*toast_opt = *main_opt;
+	}
+
+	for (int i = 0; i < lengthof(int_offsets_2); i++)
+	{
+		int		   *toast_opt;
+		int		   *main_opt;
+
+		toast_opt = (int *) ((char *) toast_avopts + int_offsets_2[i]);
+		main_opt = (int *) ((char *) main_avopts + int_offsets_2[i]);
+
+		if (*toast_opt == -2)
+			*toast_opt = *main_opt;
+	}
+
+	for (int i = 0; i < lengthof(float_offsets); i++)
+	{
+		double	   *toast_opt;
+		double	   *main_opt;
+
+		toast_opt = (double *) ((char *) toast_avopts + float_offsets[i]);
+		main_opt = (double *) ((char *) main_avopts + float_offsets[i]);
+
+		if (*toast_opt == -1.0)
+			*toast_opt = *main_opt;
+	}
+
+	return toast_opts;
+}
diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h
index 956d9cea36d..335d1516a7e 100644
--- a/src/include/commands/vacuum.h
+++ b/src/include/commands/vacuum.h
@@ -248,6 +248,14 @@ typedef struct VacuumParams
 	 * disabled.
 	 */
 	int			nworkers;
+
+	/*
+	 * Main table fallback values for TOAST tables to inherit when they have
+	 * no setting of their own.
+	 */
+	VacOptValue main_index_cleanup;
+	VacOptValue main_truncate;
+	double		main_max_eager_freeze_failure_rate;
 } VacuumParams;
 
 /*
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index f1b96b1099d..9b55c601e94 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -306,6 +306,9 @@ typedef struct ForeignKeyCacheInfo
  * RelationGetFillFactor() and RelationGetTargetPageFreeSpace() can only
  * be applied to relations that use this format or a superset for
  * private options data.
+ *
+ * NB: When adding a new member, be sure to update merge_autovac_opts() and/or
+ * table_recheck_autovac() as necessary!
  */
  /* autovacuum-related reloptions. */
 typedef struct AutoVacOpts
diff --git a/src/test/modules/injection_points/expected/vacuum.out b/src/test/modules/injection_points/expected/vacuum.out
index 58df59fa927..0f0790a8584 100644
--- a/src/test/modules/injection_points/expected/vacuum.out
+++ b/src/test/modules/injection_points/expected/vacuum.out
@@ -79,6 +79,17 @@ NOTICE:  notice triggered for injection point vacuum-truncate-enabled
 NOTICE:  notice triggered for injection point vacuum-index-cleanup-auto
 NOTICE:  notice triggered for injection point vacuum-truncate-enabled
 RESET vacuum_truncate;
+-- TOAST table inherits main table's resolved values
+CREATE TABLE vac_tab_toast_inherit(i int, j text) WITH
+  (autovacuum_enabled=false,
+   vacuum_index_cleanup=false,
+   vacuum_truncate=false, toast.vacuum_truncate=true);
+VACUUM vac_tab_toast_inherit;
+NOTICE:  notice triggered for injection point vacuum-index-cleanup-disabled
+NOTICE:  notice triggered for injection point vacuum-truncate-disabled
+NOTICE:  notice triggered for injection point vacuum-index-cleanup-disabled
+NOTICE:  notice triggered for injection point vacuum-truncate-enabled
+DROP TABLE vac_tab_toast_inherit;
 DROP TABLE vac_tab_auto;
 DROP TABLE vac_tab_on_toast_off;
 DROP TABLE vac_tab_off_toast_on;
diff --git a/src/test/modules/injection_points/sql/vacuum.sql b/src/test/modules/injection_points/sql/vacuum.sql
index 23760dd0f38..11a371e4ff7 100644
--- a/src/test/modules/injection_points/sql/vacuum.sql
+++ b/src/test/modules/injection_points/sql/vacuum.sql
@@ -33,6 +33,14 @@ SET vacuum_truncate = true;
 VACUUM vac_tab_auto;
 RESET vacuum_truncate;
 
+-- TOAST table inherits main table's resolved values
+CREATE TABLE vac_tab_toast_inherit(i int, j text) WITH
+  (autovacuum_enabled=false,
+   vacuum_index_cleanup=false,
+   vacuum_truncate=false, toast.vacuum_truncate=true);
+VACUUM vac_tab_toast_inherit;
+DROP TABLE vac_tab_toast_inherit;
+
 DROP TABLE vac_tab_auto;
 DROP TABLE vac_tab_on_toast_off;
 DROP TABLE vac_tab_off_toast_on;
-- 
2.50.1 (Apple Git-155)

