On Thu, Aug 13, 2026 at 11:41:59AM -0500, Nathan Bossart wrote: > I'll plan on committing these soon to get them out of the way.
I've committed 0001-0003. Barring more feedback, I'm hoping to commit the rest soon. Here is a rebased patch set. -- nathan
>From 24bc61a37c9d6a027a90df5b982756c987b1f272 Mon Sep 17 00:00:00 2001 From: Nathan Bossart <[email protected]> Date: Fri, 7 Aug 2026 10:55:34 -0500 Subject: [PATCH v13 1/5] Simplify autovacuum's TOAST-to-main-relation reloptions map. do_autovacuum() adds an entry to this map for every relation that has a TOAST table, and uses a flag to mark the entries that have no reloptions to pass down. The unconditional entry made sense back when the payload was the main relation's OID: commit 7d4c9a5793 added the map so that the TOAST pass could find the parent, whose pg_autovacuum row supplied the settings for a TOAST table that had none of its own. Commit 834a6da4f7 replaced that lookup by copying the parent's reloptions into the entry, which left the OID unread and called for the flag, since a by-value AutoVacOpts cannot say "not set". An entry is now worth creating only when there is something to inherit, so skip the relations that have no reloptions and let a successful lookup speak for itself. Two relations cannot share a TOAST table, and each pass sees a single catalog snapshot, so an insertion can never find an existing entry; assert that rather than quietly ignoring it. While at it, remove two more leftovers of that same conversion: ar_relid, unread ever since, and a NULL test on table_toast_map in table_recheck_autovac(), which used to carry the relkind test for get_pg_autovacuum_tuple_relid() and has had no NULL to catch since that function went away. --- src/backend/postmaster/autovacuum.c | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c index 874454891d3..e0fc551d12a 100644 --- a/src/backend/postmaster/autovacuum.c +++ b/src/backend/postmaster/autovacuum.c @@ -200,8 +200,6 @@ typedef struct avw_dbase typedef struct av_relation { Oid ar_toastrelid; /* hash key - must be first */ - Oid ar_relid; - bool ar_hasrelopts; StdRdOptions ar_reloptions; /* copy of main table's reloptions */ } av_relation; @@ -2099,7 +2097,7 @@ do_autovacuum(void) * this whether or not the table is going to be vacuumed, because we * don't automatically vacuum toast tables along the parent table. */ - if (OidIsValid(classForm->reltoastrelid)) + if (OidIsValid(classForm->reltoastrelid) && relopts) { av_relation *hentry; bool found; @@ -2107,19 +2105,10 @@ do_autovacuum(void) hentry = hash_search(table_toast_map, &classForm->reltoastrelid, HASH_ENTER, &found); + Assert(!found); /* rels cannot share a TOAST table */ - if (!found) - { - /* hash_search already filled in the key */ - hentry->ar_relid = relid; - hentry->ar_hasrelopts = false; - if (relopts != NULL) - { - hentry->ar_hasrelopts = true; - memcpy(&hentry->ar_reloptions, relopts, - sizeof(StdRdOptions)); - } - } + /* hash_search already filled in the key */ + memcpy(&hentry->ar_reloptions, relopts, sizeof(StdRdOptions)); } /* Release stuff to avoid per-relation leakage */ @@ -2168,7 +2157,7 @@ do_autovacuum(void) bool found; hentry = hash_search(table_toast_map, &relid, HASH_FIND, &found); - if (found && hentry->ar_hasrelopts) + if (found) relopts = &hentry->ar_reloptions; } @@ -2814,14 +2803,13 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map, relopts = (StdRdOptions *) extractRelOptions(classTup, pg_class_desc, NULL); if (relopts) free_relopts = true; - else if (classForm->relkind == RELKIND_TOASTVALUE && - table_toast_map != NULL) + else if (classForm->relkind == RELKIND_TOASTVALUE) { av_relation *hentry; bool found; hentry = hash_search(table_toast_map, &relid, HASH_FIND, &found); - if (found && hentry->ar_hasrelopts) + if (found) relopts = &hentry->ar_reloptions; } -- 2.55.0
>From 0118a82edbec79bd91af526e1902334a320d4103 Mon Sep 17 00:00:00 2001 From: Nathan Bossart <[email protected]> Date: Mon, 10 Aug 2026 15:55:44 -0500 Subject: [PATCH v13 2/5] Give TOAST storage parameters unsettable defaults. This is preparatory work for a follow-up commit that will fill in a TOAST table's unset storage parameters from its main table's. For that, it must be possible to tell an option nobody set from one the user set to the value that option happens to default to. The parsed form of a relation's options has nowhere to record which ones were specified, so an unset option is simply one still holding its declared default. Require, then, that any option a TOAST table accepts default to a value the user cannot set. Ternaries already comply, having no default at all, as does vacuum_index_cleanup, whose "not set" member has no spelling. Among the numeric ones only log_autovacuum_min_duration was in violation, defaulting to -1 with a minimum of -1, so give it -2 instead, matching autovacuum_vacuum_max_threshold and autovacuum_vacuum_insert_threshold. Users won't notice; -1 already behaved exactly as leaving the option unset does, and it still does. An assertion in initialize_reloptions() enforces both of the rules the follow-up commit will rely on: that the default is unsettable, and that anything settable on a TOAST table is settable on a heap, since the inherited value is read from a main table's options at the same offset. Note that this rules out bool and string options, neither of which can express "unset". --- src/backend/access/common/reloptions.c | 78 +++++++++++++++++++++++++- src/backend/postmaster/autovacuum.c | 2 +- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c index fab8294e732..a0716ff7819 100644 --- a/src/backend/access/common/reloptions.c +++ b/src/backend/access/common/reloptions.c @@ -348,7 +348,7 @@ static relopt_int intRelOpts[] = RELOPT_KIND_HEAP | RELOPT_KIND_TOAST, ShareUpdateExclusiveLock }, - -1, -1, INT_MAX + -2, -1, INT_MAX }, { { @@ -613,6 +613,78 @@ static void parse_one_reloption(relopt_value *option, char *text_str, ((option).isset ? strlen((option).string_val) : \ ((relopt_string *) (option).gen)->default_len) +#ifdef USE_ASSERT_CHECKING +/* + * Verify that every option a TOAST table accepts defaults to a value the user + * cannot set. Nothing records which options were specified, so an option + * still holding its default is the only way to recognize one that was never + * set, and that is how a TOAST table tells which values it should take from + * its main table. + */ +static void +assert_toast_defaults_unsettable(void) +{ + for (int i = 0; relOpts[i]; i++) + { + relopt_gen *gen = relOpts[i]; + + if ((gen->kinds & RELOPT_KIND_TOAST) == 0) + continue; + + /* + * A TOAST table's value is filled in from its main table's at the + * same offset in the same struct, so the option must be settable on a + * heap too. + */ + Assert((gen->kinds & RELOPT_KIND_HEAP) != 0); + + switch (gen->type) + { + case RELOPT_TYPE_TERNARY: + + /* + * Ternaries carry no default, and parse_one_reloption() can + * only produce true or false, so PG_TERNARY_UNSET is already + * beyond a user's reach. + */ + break; + + case RELOPT_TYPE_INT: + { + relopt_int *optint = (relopt_int *) gen; + + Assert(optint->default_val < optint->min || + optint->default_val > optint->max); + break; + } + + case RELOPT_TYPE_REAL: + { + relopt_real *optreal = (relopt_real *) gen; + + Assert(optreal->default_val < optreal->min || + optreal->default_val > optreal->max); + break; + } + + case RELOPT_TYPE_ENUM: + { + relopt_enum *optenum = (relopt_enum *) gen; + + for (relopt_enum_elt_def *elt = optenum->members; + elt->string_val; elt++) + Assert(elt->symbol_val != optenum->default_val); + break; + } + + default: + /* Neither bools nor strings can express "unset". */ + Assert(false); + } + } +} +#endif /* USE_ASSERT_CHECKING */ + /* * initialize_reloptions * initialization routine, must be called before parsing @@ -730,6 +802,10 @@ initialize_reloptions(void) /* flag the work is complete */ need_initialization = false; + +#ifdef USE_ASSERT_CHECKING + assert_toast_defaults_unsettable(); +#endif } /* diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c index e0fc551d12a..77557fa4bbf 100644 --- a/src/backend/postmaster/autovacuum.c +++ b/src/backend/postmaster/autovacuum.c @@ -2838,7 +2838,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map, * defaults, autovacuum's own first and plain vacuum second. */ - /* -1 in autovac setting means use log_autovacuum_min_duration */ + /* a negative autovac setting means use log_autovacuum_min_duration */ log_vacuum_min_duration = (avopts && avopts->log_vacuum_min_duration >= 0) ? avopts->log_vacuum_min_duration : Log_autovacuum_min_duration; -- 2.55.0
>From aa47cbbd04d3d5f1342d72ccfca4d0b64f37708f Mon Sep 17 00:00:00 2001 From: Nathan Bossart <[email protected]> Date: Mon, 10 Aug 2026 15:56:02 -0500 Subject: [PATCH v13 3/5] Move the StdRdOptions parse table to file scope. This is preparatory work for a follow-up commit that will walk the table from another function. Nothing changes but the indentation of its entries. --- src/backend/access/common/reloptions.c | 115 +++++++++++++------------ 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c index a0716ff7819..cffe9350d4f 100644 --- a/src/backend/access/common/reloptions.c +++ b/src/backend/access/common/reloptions.c @@ -2043,69 +2043,74 @@ fillRelOptions(void *rdopts, Size basesize, } +/* + * Parse table for StdRdOptions, which is shared by the RELOPT_KIND_HEAP and + * RELOPT_KIND_TOAST kinds. + */ +static const relopt_parse_elt stdRdOptionsTab[] = { + {"fillfactor", RELOPT_TYPE_INT, offsetof(StdRdOptions, fillfactor)}, + {"autovacuum_enabled", RELOPT_TYPE_TERNARY, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, enabled)}, + {"autovacuum_parallel_workers", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, autovacuum_parallel_workers)}, + {"autovacuum_vacuum_threshold", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_threshold)}, + {"autovacuum_vacuum_max_threshold", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_max_threshold)}, + {"autovacuum_vacuum_insert_threshold", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_ins_threshold)}, + {"autovacuum_analyze_threshold", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, analyze_threshold)}, + {"autovacuum_vacuum_cost_limit", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_cost_limit)}, + {"autovacuum_freeze_min_age", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, freeze_min_age)}, + {"autovacuum_freeze_max_age", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, freeze_max_age)}, + {"autovacuum_freeze_table_age", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, freeze_table_age)}, + {"autovacuum_multixact_freeze_min_age", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, multixact_freeze_min_age)}, + {"autovacuum_multixact_freeze_max_age", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, multixact_freeze_max_age)}, + {"autovacuum_multixact_freeze_table_age", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, multixact_freeze_table_age)}, + {"log_autovacuum_min_duration", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, log_vacuum_min_duration)}, + {"log_autoanalyze_min_duration", RELOPT_TYPE_INT, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, log_analyze_min_duration)}, + {"toast_tuple_target", RELOPT_TYPE_INT, + offsetof(StdRdOptions, toast_tuple_target)}, + {"autovacuum_vacuum_cost_delay", RELOPT_TYPE_REAL, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_cost_delay)}, + {"autovacuum_vacuum_scale_factor", RELOPT_TYPE_REAL, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_scale_factor)}, + {"autovacuum_vacuum_insert_scale_factor", RELOPT_TYPE_REAL, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_ins_scale_factor)}, + {"autovacuum_analyze_scale_factor", RELOPT_TYPE_REAL, + offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, analyze_scale_factor)}, + {"user_catalog_table", RELOPT_TYPE_BOOL, + offsetof(StdRdOptions, user_catalog_table)}, + {"parallel_workers", RELOPT_TYPE_INT, + offsetof(StdRdOptions, parallel_workers)}, + {"vacuum_index_cleanup", RELOPT_TYPE_ENUM, + offsetof(StdRdOptions, vacuum_index_cleanup)}, + {"vacuum_truncate", RELOPT_TYPE_TERNARY, + offsetof(StdRdOptions, vacuum_truncate)}, + {"vacuum_max_eager_freeze_failure_rate", RELOPT_TYPE_REAL, + offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)} +}; + /* * Option parser for anything that uses StdRdOptions. */ bytea * default_reloptions(Datum reloptions, bool validate, relopt_kind kind) { - static const relopt_parse_elt tab[] = { - {"fillfactor", RELOPT_TYPE_INT, offsetof(StdRdOptions, fillfactor)}, - {"autovacuum_enabled", RELOPT_TYPE_TERNARY, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, enabled)}, - {"autovacuum_parallel_workers", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, autovacuum_parallel_workers)}, - {"autovacuum_vacuum_threshold", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_threshold)}, - {"autovacuum_vacuum_max_threshold", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_max_threshold)}, - {"autovacuum_vacuum_insert_threshold", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_ins_threshold)}, - {"autovacuum_analyze_threshold", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, analyze_threshold)}, - {"autovacuum_vacuum_cost_limit", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_cost_limit)}, - {"autovacuum_freeze_min_age", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, freeze_min_age)}, - {"autovacuum_freeze_max_age", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, freeze_max_age)}, - {"autovacuum_freeze_table_age", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, freeze_table_age)}, - {"autovacuum_multixact_freeze_min_age", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, multixact_freeze_min_age)}, - {"autovacuum_multixact_freeze_max_age", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, multixact_freeze_max_age)}, - {"autovacuum_multixact_freeze_table_age", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, multixact_freeze_table_age)}, - {"log_autovacuum_min_duration", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, log_vacuum_min_duration)}, - {"log_autoanalyze_min_duration", RELOPT_TYPE_INT, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, log_analyze_min_duration)}, - {"toast_tuple_target", RELOPT_TYPE_INT, - offsetof(StdRdOptions, toast_tuple_target)}, - {"autovacuum_vacuum_cost_delay", RELOPT_TYPE_REAL, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_cost_delay)}, - {"autovacuum_vacuum_scale_factor", RELOPT_TYPE_REAL, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_scale_factor)}, - {"autovacuum_vacuum_insert_scale_factor", RELOPT_TYPE_REAL, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, vacuum_ins_scale_factor)}, - {"autovacuum_analyze_scale_factor", RELOPT_TYPE_REAL, - offsetof(StdRdOptions, autovacuum) + offsetof(AutoVacOpts, analyze_scale_factor)}, - {"user_catalog_table", RELOPT_TYPE_BOOL, - offsetof(StdRdOptions, user_catalog_table)}, - {"parallel_workers", RELOPT_TYPE_INT, - offsetof(StdRdOptions, parallel_workers)}, - {"vacuum_index_cleanup", RELOPT_TYPE_ENUM, - offsetof(StdRdOptions, vacuum_index_cleanup)}, - {"vacuum_truncate", RELOPT_TYPE_TERNARY, - offsetof(StdRdOptions, vacuum_truncate)}, - {"vacuum_max_eager_freeze_failure_rate", RELOPT_TYPE_REAL, - offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)} - }; - return (bytea *) build_reloptions(reloptions, validate, kind, sizeof(StdRdOptions), - tab, lengthof(tab)); + stdRdOptionsTab, + lengthof(stdRdOptionsTab)); } /* -- 2.55.0
>From c232a2c7220844697da250d56ed63988cb159a8c Mon Sep 17 00:00:00 2001 From: Nathan Bossart <[email protected]> Date: Wed, 12 Aug 2026 11:34:55 -0500 Subject: [PATCH v13 4/5] 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. --- src/backend/access/common/reloptions.c | 103 ++++++++++++++++++ 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, 162 insertions(+), 11 deletions(-) diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c index cffe9350d4f..bbd40070a06 100644 --- a/src/backend/access/common/reloptions.c +++ b/src/backend/access/common/reloptions.c @@ -2113,6 +2113,109 @@ 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(StdRdOptions *toast_opts, 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; + 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 = (char *) main_opts + elem->offset; + + switch (gen->type) + { + case RELOPT_TYPE_TERNARY: + if (*(pg_ternary *) toast_val == PG_TERNARY_UNSET) + *(pg_ternary *) toast_val = *(pg_ternary *) main_val; + break; + + case RELOPT_TYPE_INT: + if (*(int *) toast_val == ((relopt_int *) gen)->default_val) + *(int *) toast_val = *(int *) main_val; + break; + + case RELOPT_TYPE_REAL: + if (*(double *) toast_val == ((relopt_real *) gen)->default_val) + *(double *) toast_val = *(double *) main_val; + break; + + case RELOPT_TYPE_ENUM: + if (*(int *) toast_val == ((relopt_enum *) gen)->default_val) + *(int *) toast_val = *(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 77557fa4bbf..0b1fcec7b29 100644 --- a/src/backend/postmaster/autovacuum.c +++ b/src/backend/postmaster/autovacuum.c @@ -2897,6 +2897,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..6c599382f02 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(struct StdRdOptions *toast_opts, + 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..55ce22f4c5b 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. + */ + 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 ada6537d99e5bed2aa1a5d1d47c6daa4d7f7ac16 Mon Sep 17 00:00:00 2001 From: Nathan Bossart <[email protected]> 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
