I've committed everything but the last two patches.  I've attached a
rebased patch set.

On Wed, Aug 19, 2026 at 04:16:40PM +0900, Michael Paquier wrote:
> The API contract in v13-0004 looks much better to me now.  No more
> overwrites of the inputs.  It's almost like you could add some const
> markers.

Done in v14.

> At the end of the day, get_effective_relopts() acts as a thin wrapper
> of extractRelOptions(), merging two existing code patterns and
> re-using the same pattern for the scoring.  Perhaps "effective" is the
> term that troubles me here, while having merge_toast_reloptions().
> You need the merge_*() for the vacuum part, but I'm also wondering if
> this could not be reworked with less routines overall.  I don't have a
> clean idea on top of my mind now, and that does not count as an
> objection.  This gives an impression of being slightly
> overcomplicated.

I haven't thought of anything better.

> The test looks pretty nice here.

Thanks for reviewing!

-- 
nathan
>From d02ad463b6d269f89ae1b202014667f1814cac00 Mon Sep 17 00:00:00 2001
From: Nathan Bossart <[email protected]>
Date: Fri, 21 Aug 2026 16:47:17 -0500
Subject: [PATCH v14 1/2] Fix VACUUM's 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.

Presently, VACUUM does no such thing.  It reads the TOAST table's
own reloptions, which hold only what was set through toast.*, so
vacuum_index_cleanup or vacuum_truncate set on the main table has
no effect on its TOAST table.

To fix, add merge_toast_reloptions(), which walks the parse table
for StdRdOptions and takes the main table's value for anything the
TOAST table left at its default.  vacuum_rel() hands the main
table's parameters down when recursing to a TOAST table, and it
merges them into a copy of the TOAST table's parameters before the
values are used.  This doesn't help 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).

A follow-up commit will do the same for autovacuum.  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.

Reviewed-by: Michael Paquier <[email protected]>
Reviewed-by: Sami Imseih <[email protected]>
Reviewed-by: Greg Burd <[email protected]>
Tested-by: solai v <[email protected]>
Discussion: https://postgr.es/m/aFRxC1W_kZU9OjJ9%40nathan
---
 src/backend/access/common/reloptions.c        | 104 ++++++++++++++++++
 src/backend/commands/vacuum.c                 |  40 +++++--
 src/backend/postmaster/autovacuum.c           |   1 +
 src/include/access/reloptions.h               |   2 +
 src/include/commands/vacuum.h                 |   8 ++
 .../injection_points/expected/vacuum.out      |  11 ++
 .../modules/injection_points/sql/vacuum.sql   |   8 ++
 7 files changed, 163 insertions(+), 11 deletions(-)

diff --git a/src/backend/access/common/reloptions.c 
b/src/backend/access/common/reloptions.c
index d7e63c10d58..ca5a0726dda 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -2110,6 +2110,110 @@ default_reloptions(Datum reloptions, bool validate, 
relopt_kind kind)
                                                                          
lengthof(stdRdOptionsTab));
 }
 
+/*
+ * find_reloption
+ *             Look up a reloption of the given kind by name.
+ *
+ * Returns NULL if no such option can be set on relations of that kind.  Note
+ * that names are unique only within a kind; "fillfactor", for example, is
+ * declared separately for heaps and for each index access method.
+ */
+static relopt_gen *
+find_reloption(const char *name, relopt_kind kind)
+{
+       if (need_initialization)
+               initialize_reloptions();
+
+       for (int i = 0; relOpts[i]; i++)
+       {
+               if ((relOpts[i]->kinds & kind) != 0 &&
+                       strcmp(relOpts[i]->name, name) == 0)
+                       return relOpts[i];
+       }
+
+       return NULL;
+}
+
+/*
+ * merge_toast_reloptions
+ *             Fill in a TOAST table's unset options from its main table's.
+ *
+ * Any option that may be set on a TOAST table but was not is taken from
+ * main_opts.  Either argument may be NULL; if both are, NULL is returned.
+ * Otherwise, the options to use are returned.
+ *
+ * An option counts as unset while it still holds the default declared for it
+ * above, which works because nothing a TOAST table accepts has a default the
+ * user could also set (see assert_toast_defaults_unsettable()).
+ *
+ * If the return value is not NULL, it is palloc'd.
+ */
+StdRdOptions *
+merge_toast_reloptions(const StdRdOptions *toast_opts,
+                                          const StdRdOptions *main_opts)
+{
+       StdRdOptions *ret;
+
+       /* if both arguments are NULL, return NULL */
+       if (toast_opts == NULL && main_opts == NULL)
+               return NULL;
+
+       /* if one argument is NULL, return the non-NULL one */
+       ret = palloc_object(StdRdOptions);
+       if (toast_opts == NULL || main_opts == NULL)
+       {
+               memcpy(ret, main_opts ? main_opts : toast_opts, 
sizeof(StdRdOptions));
+               return ret;
+       }
+
+       /* replace unset TOAST relopts with the main table's */
+       memcpy(ret, toast_opts, sizeof(StdRdOptions));
+       for (int i = 0; i < lengthof(stdRdOptionsTab); i++)
+       {
+               const relopt_parse_elt *elem = &stdRdOptionsTab[i];
+               relopt_gen *gen;
+               char       *toast_val;
+               const char *main_val;
+
+               /* skip anything that cannot be set on a TOAST table */
+               gen = find_reloption(elem->optname, RELOPT_KIND_TOAST);
+               if (gen == NULL)
+                       continue;
+
+               toast_val = (char *) ret + elem->offset;
+               main_val = (const char *) main_opts + elem->offset;
+
+               switch (gen->type)
+               {
+                       case RELOPT_TYPE_TERNARY:
+                               if (*(pg_ternary *) toast_val == 
PG_TERNARY_UNSET)
+                                       *(pg_ternary *) toast_val = *(const 
pg_ternary *) main_val;
+                               break;
+
+                       case RELOPT_TYPE_INT:
+                               if (*(int *) toast_val == ((relopt_int *) 
gen)->default_val)
+                                       *(int *) toast_val = *(const int *) 
main_val;
+                               break;
+
+                       case RELOPT_TYPE_REAL:
+                               if (*(double *) toast_val == ((relopt_real *) 
gen)->default_val)
+                                       *(double *) toast_val = *(const double 
*) main_val;
+                               break;
+
+                       case RELOPT_TYPE_ENUM:
+                               if (*(int *) toast_val == ((relopt_enum *) 
gen)->default_val)
+                                       *(int *) toast_val = *(const int *) 
main_val;
+                               break;
+
+                       default:
+                               elog(ERROR, "reloption \"%s\" has a type a 
TOAST table cannot inherit",
+                                        elem->optname);
+               }
+       }
+
+       return ret;
+}
+
 /*
  * build_reloptions
  *
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 64eed16a160..458cf956c10 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -31,6 +31,7 @@
 #include "access/heapam.h"
 #include "access/htup_details.h"
 #include "access/multixact.h"
+#include "access/reloptions.h"
 #include "access/tableam.h"
 #include "access/transam.h"
 #include "access/xact.h"
@@ -187,6 +188,7 @@ ExecVacuum(ParseState *pstate, VacuumStmt *vacstmt, bool 
isTopLevel)
 
        /* Will be set later if we recurse to a TOAST table. */
        params.toast_parent = InvalidOid;
+       params.main_relopts = NULL;
 
        /*
         * Set this to an invalid value so it is clear whether or not a
@@ -2039,6 +2041,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams 
params,
        int                     save_sec_context;
        int                     save_nestlevel;
        VacuumParams toast_vacuum_params;
+       StdRdOptions *relopts;
+       StdRdOptions relopts_copy;
 
        /*
         * This function scribbles on the parameters, so make a copy early to
@@ -2201,6 +2205,14 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams 
params,
        lockrelid = rel->rd_lockInfo.lockRelId;
        LockRelationIdForSession(&lockrelid, lmode);
 
+       /*
+        * A TOAST table takes any storage parameter it accepts but does not set
+        * from its main table, whose parameters the caller handed down for that
+        * purpose.  Merge them into a copy of our own.
+        */
+       relopts = merge_toast_reloptions((StdRdOptions *) rel->rd_options,
+                                                                        
params.main_relopts);
+
        /*
         * Set index_cleanup option based on index_cleanup reloption if it 
wasn't
         * specified in VACUUM command, or when running in an autovacuum worker
@@ -2209,11 +2221,10 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams 
params,
        {
                StdRdOptIndexCleanup vacuum_index_cleanup;
 
-               if (rel->rd_options == NULL)
+               if (relopts == NULL)
                        vacuum_index_cleanup = 
STDRD_OPTION_VACUUM_INDEX_CLEANUP_NOT_SET;
                else
-                       vacuum_index_cleanup =
-                               ((StdRdOptions *) 
rel->rd_options)->vacuum_index_cleanup;
+                       vacuum_index_cleanup = relopts->vacuum_index_cleanup;
 
                switch (vacuum_index_cleanup)
                {
@@ -2245,10 +2256,8 @@ 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.
         */
-       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;
+       if (relopts != NULL && relopts->vacuum_max_eager_freeze_failure_rate >= 
0)
+               params.max_eager_freeze_failure_rate = 
relopts->vacuum_max_eager_freeze_failure_rate;
 
        /*
         * Set truncate option based on truncate reloption or GUC if it wasn't
@@ -2256,11 +2265,9 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams 
params,
         */
        if (params.truncate == VACOPTVALUE_UNSPECIFIED)
        {
-               StdRdOptions *opts = (StdRdOptions *) rel->rd_options;
-
-               if (opts && opts->vacuum_truncate != PG_TERNARY_UNSET)
+               if (relopts && relopts->vacuum_truncate != PG_TERNARY_UNSET)
                {
-                       if (opts->vacuum_truncate == PG_TERNARY_TRUE)
+                       if (relopts->vacuum_truncate == PG_TERNARY_TRUE)
                                params.truncate = VACOPTVALUE_ENABLED;
                        else
                                params.truncate = VACOPTVALUE_DISABLED;
@@ -2293,6 +2300,17 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams 
params,
        else
                toast_relid = InvalidOid;
 
+       /*
+        * Hand our storage parameters down for the TOAST table to inherit.  
Take
+        * a copy while we still have the relation open; the relcache entry can 
go
+        * away once we close it.
+        */
+       if (OidIsValid(toast_relid) && rel->rd_options)
+       {
+               memcpy(&relopts_copy, rel->rd_options, sizeof(StdRdOptions));
+               toast_vacuum_params.main_relopts = &relopts_copy;
+       }
+
        /*
         * Switch to the table owner's userid, so that any index functions are 
run
         * as that user.  Also lock down security-restricted operations and
diff --git a/src/backend/postmaster/autovacuum.c 
b/src/backend/postmaster/autovacuum.c
index 9c73c10fb11..30d6a5feb17 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -2895,6 +2895,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;
 
                /* Determine the number of parallel vacuum workers to use */
                tab->at_params.nworkers = 0;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index e8cb7f7a627..ccff4717b62 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -247,6 +247,8 @@ extern void *build_local_reloptions(local_relopts *relopts, 
Datum options,
 
 extern bytea *default_reloptions(Datum reloptions, bool validate,
                                                                 relopt_kind 
kind);
+extern struct StdRdOptions *merge_toast_reloptions(const struct StdRdOptions 
*toast_opts,
+                                                                               
                   const struct StdRdOptions *main_opts);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
diff --git a/src/include/commands/vacuum.h b/src/include/commands/vacuum.h
index e62f23748dc..6e3c912bf5c 100644
--- a/src/include/commands/vacuum.h
+++ b/src/include/commands/vacuum.h
@@ -248,6 +248,14 @@ typedef struct VacuumParams
         * disabled.
         */
        int                     nworkers;
+
+       /*
+        * When vacuuming a TOAST table, this holds the main table's storage
+        * parameters (or NULL if it doesn't have any).  If a storage parameter 
is
+        * unset on the TOAST table but _is_ set on the main table, we use the
+        * main table's setting.
+        */
+       const struct StdRdOptions *main_relopts;
 } VacuumParams;
 
 /*
diff --git a/src/test/modules/injection_points/expected/vacuum.out 
b/src/test/modules/injection_points/expected/vacuum.out
index 58df59fa927..caf0cc232b4 100644
--- a/src/test/modules/injection_points/expected/vacuum.out
+++ b/src/test/modules/injection_points/expected/vacuum.out
@@ -79,9 +79,20 @@ 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;
+-- A TOAST table inherits what it does not set from its main table.
+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_auto;
 DROP TABLE vac_tab_on_toast_off;
 DROP TABLE vac_tab_off_toast_on;
+DROP TABLE vac_tab_toast_inherit;
 -- Cleanup
 SELECT injection_points_detach('vacuum-index-cleanup-auto');
  injection_points_detach 
diff --git a/src/test/modules/injection_points/sql/vacuum.sql 
b/src/test/modules/injection_points/sql/vacuum.sql
index 23760dd0f38..0a43e14c928 100644
--- a/src/test/modules/injection_points/sql/vacuum.sql
+++ b/src/test/modules/injection_points/sql/vacuum.sql
@@ -33,9 +33,17 @@ SET vacuum_truncate = true;
 VACUUM vac_tab_auto;
 RESET vacuum_truncate;
 
+-- A TOAST table inherits what it does not set from its main table.
+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_auto;
 DROP TABLE vac_tab_on_toast_off;
 DROP TABLE vac_tab_off_toast_on;
+DROP TABLE vac_tab_toast_inherit;
 
 -- Cleanup
 SELECT injection_points_detach('vacuum-index-cleanup-auto');
-- 
2.55.0

>From bbad7a91083b6e2cfed1d69132e989fc7f18b316 Mon Sep 17 00:00:00 2001
From: Nathan Bossart <[email protected]>
Date: Fri, 21 Aug 2026 17:01:41 -0500
Subject: [PATCH v14 2/2] 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.

Reviewed-by: Michael Paquier <[email protected]>
Reviewed-by: Sami Imseih <[email protected]>
Reviewed-by: Greg Burd <[email protected]>
Tested-by: solai v <[email protected]>
Discussion: https://postgr.es/m/aFRxC1W_kZU9OjJ9%40nathan
---
 src/backend/postmaster/autovacuum.c           | 151 +++++++++++++-----
 src/test/modules/test_autovacuum/meson.build  |   1 +
 .../test_autovacuum/t/002_toast_relopts.pl    |  69 ++++++++
 3 files changed, 181 insertions(+), 40 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 30d6a5feb17..e44884f3ef8 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -1913,6 +1913,45 @@ 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 is returned.
+ *
+ * If "tup" refers to a TOAST table and "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;
+       StdRdOptions *ret;
+       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 */
+       ret = merge_toast_reloptions(relopts, *main_opts);
+
+       if (relopts)
+               pfree(relopts);
+       return ret;
+}
+
 /*
  * Process a database table-by-table
  *
@@ -2015,9 +2054,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 +2169,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,21 +2183,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;
-
-                       hentry = hash_search(table_toast_map, &relid, 
HASH_FIND, NULL);
-                       if (hentry)
-                               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,
@@ -2179,7 +2206,7 @@ do_autovacuum(void)
                }
 
                /* Release stuff to avoid leakage */
-               if (free_relopts)
+               if (relopts)
                        pfree(relopts);
        }
 
@@ -2786,7 +2813,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 */
@@ -2796,20 +2823,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;
-
-               hentry = hash_search(table_toast_map, &relid, HASH_FIND, NULL);
-               if (hentry)
-                       relopts = &hentry->ar_reloptions;
-       }
+       relopts = get_effective_relopts(classTup, pg_class_desc, 
table_toast_map,
+                                                                       
&main_relopts);
 
        avopts = relopts ? &relopts->autovacuum : NULL;
 
@@ -2895,7 +2913,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;
@@ -2940,7 +2958,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;
@@ -2954,8 +2972,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
@@ -3612,6 +3630,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);
 
@@ -3620,13 +3640,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;
@@ -3642,7 +3711,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,
@@ -3668,6 +3738,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

Reply via email to