pg.ddx.io  pgsql-hackers@postgresql.org mailing list archive  
help / color / mirror / Atom feed
[PATCH v2 2/4] Introduce ternary reloptions
45+ messages / 10 participants
[nested] [flat]

Commits from this thread:
  2026-07-20 83486b6a65  Fix recovery target test waiting on unavailable WAL
  2026-07-20 d5751c33cc  Avoid ERROR in recovery target GUC assign hooks
  2026-07-20 1009339b3a  Allow PostgreSQL::Test::Cluster::start() to pass postmaster options
* [PATCH v2 2/4] Introduce ternary reloptions
@ 2025-09-04 14:41  Nikolay Shaplov <dhyan@nataraj.su>
  0 siblings, 0 replies; 45+ messages in thread

From: Nikolay Shaplov @ 2025-09-04 14:41 UTC (permalink / raw)

Introduce ternary reloption as a replacement for current `vacuum_truncate`
implementation. Remove `vacuum_truncate_set` additional flag and using
`TERNARY_UNSET` value instead.
---
 src/backend/access/common/reloptions.c | 129 ++++++++++++++++++++-----
 src/backend/commands/vacuum.c          |   4 +-
 src/include/access/reloptions.h        |  26 ++---
 src/include/c.h                        |  16 +++
 src/include/utils/rel.h                |   3 +-
 5 files changed, 135 insertions(+), 43 deletions(-)

inline patch 1: src/backend/access/common/reloptions.c, src/backend/commands/vacuum.c, src/include/access/reloptions.h and 2 more (+135/-43 lines) download inline-1.patchdiff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c index 0af3fea68f..f543f11001 100644 --- a/src/backend/access/common/reloptions.c +++ b/src/backend/access/common/reloptions.c @@ -40,9 +40,9 @@ * * To add an option: * - * (i) decide on a type (bool, integer, real, enum, string), name, default - * value, upper and lower bounds (if applicable); for strings, consider a - * validation routine. + * (i) decide on a type (bool, ternary, integer, real, enum, string), name, + * default value, upper and lower bounds (if applicable); for strings, + * consider a validation routine. * (ii) add a record below (or use add_<type>_reloption). * (iii) add it to the appropriate options struct (perhaps StdRdOptions) * (iv) add it to the appropriate handling routine (perhaps @@ -147,15 +147,6 @@ static relopt_bool boolRelOpts[] = }, false }, - { - { - "vacuum_truncate", - "Enables vacuum to truncate empty pages at the end of this table", - RELOPT_KIND_HEAP | RELOPT_KIND_TOAST, - ShareUpdateExclusiveLock - }, - true - }, { { "deduplicate_items", @@ -170,6 +161,21 @@ static relopt_bool boolRelOpts[] = {{NULL}} }; +static relopt_ternary ternaryRelOpts[] = +{ + { + { + "vacuum_truncate", + "Enables vacuum to truncate empty pages at the end of this table", + RELOPT_KIND_HEAP | RELOPT_KIND_TOAST, + ShareUpdateExclusiveLock + }, + TERNARY_UNSET + }, + /* list terminator */ + {{NULL}} +}; + static relopt_int intRelOpts[] = { { @@ -600,6 +606,13 @@ initialize_reloptions(void) boolRelOpts[i].gen.lockmode)); j++; } + for (i = 0; ternaryRelOpts[i].gen.name; i++) + { + Assert(DoLockModesConflict(ternaryRelOpts[i].gen.lockmode, + ternaryRelOpts[i].gen.lockmode)); + j++; + } + for (i = 0; intRelOpts[i].gen.name; i++) { Assert(DoLockModesConflict(intRelOpts[i].gen.lockmode, @@ -640,6 +653,14 @@ initialize_reloptions(void) j++; } + for (i = 0; ternaryRelOpts[i].gen.name; i++) + { + relOpts[j] = &ternaryRelOpts[i].gen; + relOpts[j]->type = RELOPT_TYPE_TERNARY; + relOpts[j]->namelen = strlen(relOpts[j]->name); + j++; + } + for (i = 0; intRelOpts[i].gen.name; i++) { relOpts[j] = &intRelOpts[i].gen; @@ -800,6 +821,9 @@ allocate_reloption(bits32 kinds, int type, const char *name, const char *desc, case RELOPT_TYPE_BOOL: size = sizeof(relopt_bool); break; + case RELOPT_TYPE_TERNARY: + size = sizeof(relopt_ternary); + break; case RELOPT_TYPE_INT: size = sizeof(relopt_int); break; @@ -883,6 +907,54 @@ add_local_bool_reloption(local_relopts *relopts, const char *name, add_local_reloption(relopts, (relopt_gen *) newoption, offset); } +/* + * init_ternary_reloption + * Allocate and initialize a new ternary reloption + */ +static relopt_ternary * +init_ternary_reloption(bits32 kinds, const char *name, const char *desc, + ternary default_val, LOCKMODE lockmode) +{ + relopt_ternary *newoption; + + newoption = (relopt_ternary *) allocate_reloption(kinds, + RELOPT_TYPE_TERNARY, name, desc, lockmode); + newoption->default_val = default_val; + + return newoption; +} + +/* + * add_ternary_reloption + * Add a new ternary reloption + */ +void +add_ternary_reloption(bits32 kinds, const char *name, const char *desc, + ternary default_val, LOCKMODE lockmode) +{ + relopt_ternary *newoption = init_ternary_reloption(kinds, name, desc, + default_val, lockmode); + + add_reloption((relopt_gen *) newoption); +} + +/* + * add_local_ternary_reloption + * Add a new ternary local reloption + * + * 'offset' is offset of ternary-typed field. + */ +void +add_local_ternary_reloption(local_relopts *relopts, const char *name, + const char *desc, ternary default_val, + int offset) +{ + relopt_ternary *newoption = init_ternary_reloption(RELOPT_KIND_LOCAL, + name, desc, + default_val, 0); + + add_local_reloption(relopts, (relopt_gen *) newoption, offset); +} /* * init_real_reloption @@ -1617,6 +1689,18 @@ parse_one_reloption(relopt_value *option, char *text_str, int text_len, option->gen->name, value))); } break; + case RELOPT_TYPE_TERNARY: + { + bool b; + parsed = parse_bool(value, &b); + option->values.ternary_val = b ? TERNARY_TRUE : TERNARY_FALSE; + if (validate && !parsed) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("invalid value for ternary option \"%s\": %s", + option->gen->name, value))); + } + break; case RELOPT_TYPE_INT: { relopt_int *optint = (relopt_int *) option->gen; @@ -1780,17 +1864,6 @@ fillRelOptions(void *rdopts, Size basesize, char *itempos = ((char *) rdopts) + elems[j].offset; char *string_val; - /* - * If isset_offset is provided, store whether the reloption is - * set there. - */ - if (elems[j].isset_offset > 0) - { - char *setpos = ((char *) rdopts) + elems[j].isset_offset; - - *(bool *) setpos = options[i].isset; - } - switch (options[i].gen->type) { case RELOPT_TYPE_BOOL: @@ -1798,6 +1871,11 @@ fillRelOptions(void *rdopts, Size basesize, options[i].values.bool_val : ((relopt_bool *) options[i].gen)->default_val; break; + case RELOPT_TYPE_TERNARY: + *(ternary *) itempos = options[i].isset ? + options[i].values.ternary_val : + ((relopt_ternary *) options[i].gen)->default_val; + break; case RELOPT_TYPE_INT: *(int *) itempos = options[i].isset ? options[i].values.int_val : @@ -1912,8 +1990,8 @@ default_reloptions(Datum reloptions, bool validate, relopt_kind kind) offsetof(StdRdOptions, parallel_workers)}, {"vacuum_index_cleanup", RELOPT_TYPE_ENUM, offsetof(StdRdOptions, vacuum_index_cleanup)}, - {"vacuum_truncate", RELOPT_TYPE_BOOL, - offsetof(StdRdOptions, vacuum_truncate), offsetof(StdRdOptions, vacuum_truncate_set)}, + {"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)} }; @@ -1993,7 +2071,6 @@ build_local_reloptions(local_relopts *relopts, Datum options, bool validate) elems[i].optname = opt->option->name; elems[i].opttype = opt->option->type; elems[i].offset = opt->offset; - elems[i].isset_offset = 0; /* not supported for local relopts yet */ i++; } diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c index 733ef40ae7..977babff54 100644 --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -2221,9 +2221,9 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params, { StdRdOptions *opts = (StdRdOptions *) rel->rd_options; - if (opts && opts->vacuum_truncate_set) + if (opts && opts->vacuum_truncate != TERNARY_UNSET) { - if (opts->vacuum_truncate) + if (opts->vacuum_truncate == TERNARY_TRUE) params.truncate = VACOPTVALUE_ENABLED; else params.truncate = VACOPTVALUE_DISABLED; diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h index a604a4702c..a436697658 100644 --- a/src/include/access/reloptions.h +++ b/src/include/access/reloptions.h @@ -29,6 +29,7 @@ typedef enum relopt_type { RELOPT_TYPE_BOOL, + RELOPT_TYPE_TERNARY, /* on, off, unset */ RELOPT_TYPE_INT, RELOPT_TYPE_REAL, RELOPT_TYPE_ENUM, @@ -80,6 +81,7 @@ typedef struct relopt_value union { bool bool_val; + ternary ternary_val; int int_val; double real_val; int enum_val; @@ -94,6 +96,12 @@ typedef struct relopt_bool bool default_val; } relopt_bool; +typedef struct relopt_rernary +{ + relopt_gen gen; + int default_val; +} relopt_ternary; + typedef struct relopt_int { relopt_gen gen; @@ -152,19 +160,6 @@ typedef struct const char *optname; /* option's name */ relopt_type opttype; /* option's datatype */ int offset; /* offset of field in result struct */ - - /* - * isset_offset is an optional offset of a field in the result struct that - * stores whether the option is explicitly set for the relation or if it - * just picked up the default value. In most cases, this can be - * accomplished by giving the reloption a special out-of-range default - * value (e.g., some integer reloptions use -2), but this isn't always - * possible. For example, a Boolean reloption cannot be given an - * out-of-range default, so we need another way to discover the source of - * its value. This offset is only used if given a value greater than - * zero. - */ - int isset_offset; } relopt_parse_elt; /* Local reloption definition */ @@ -195,6 +190,8 @@ typedef struct local_relopts extern relopt_kind add_reloption_kind(void); extern void add_bool_reloption(bits32 kinds, const char *name, const char *desc, bool default_val, LOCKMODE lockmode); +extern void add_ternary_reloption(bits32 kinds, const char *name, + const char *desc, int default_val, LOCKMODE lockmode); extern void add_int_reloption(bits32 kinds, const char *name, const char *desc, int default_val, int min_val, int max_val, LOCKMODE lockmode); @@ -214,6 +211,9 @@ extern void register_reloptions_validator(local_relopts *relopts, extern void add_local_bool_reloption(local_relopts *relopts, const char *name, const char *desc, bool default_val, int offset); +extern void add_local_ternary_reloption(local_relopts *relopts, + const char *name, const char *desc, + ternary default_val, int offset); extern void add_local_int_reloption(local_relopts *relopts, const char *name, const char *desc, int default_val, int min_val, int max_val, int offset); diff --git a/src/include/c.h b/src/include/c.h index 39022f8a9d..9c59ccccf8 100644 --- a/src/include/c.h +++ b/src/include/c.h @@ -488,6 +488,22 @@ typedef void (*pg_funcptr_t) (void); #include <stdbool.h> +/* + * ternary + * Boolean value with an extrea "unset" option + * + * Ternary data type is used in relation options that can be "true", "false" or + * "unset". Since relation options are used deep inside the PostgreSQL code, + * this type is declared globally. +*/ + +typedef enum ternary +{ + TERNARY_FALSE = 0, + TERNARY_TRUE = 1, + TERNARY_UNSET = -1 +} ternary; + /* ---------------------------------------------------------------- * Section 3: standard system types diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h index b552359915..7346d618f9 100644 --- a/src/include/utils/rel.h +++ b/src/include/utils/rel.h @@ -346,8 +346,7 @@ typedef struct StdRdOptions bool user_catalog_table; /* use as an additional catalog relation */ int parallel_workers; /* max number of parallel workers */ StdRdOptIndexCleanup vacuum_index_cleanup; /* controls index vacuuming */ - bool vacuum_truncate; /* enables vacuum to truncate a relation */ - bool vacuum_truncate_set; /* whether vacuum_truncate is set */ + ternary vacuum_truncate; /* enables vacuum to truncate a relation */ /* * Fraction of pages in a relation that vacuum can eagerly scan and fail -- 2.39.2
--nextPart5129932.5fSG56mABF Content-Disposition: attachment; filename="v2-0003-Add-alias-to-be-used-as-unset-state.patch" Content-Transfer-Encoding: 7Bit Content-Type: text/x-patch; charset="unicode-2-0-utf-8"; name="v2-0003-Add-alias-to-be-used-as-unset-state.patch" ^ permalink raw reply [nested|flat] 45+ messages in thread

* [PATCH v2a 2/4] Introduce ternary reloptions
@ 2025-09-04 14:41  Nikolay Shaplov <dhyan@nataraj.su>
  0 siblings, 0 replies; 45+ messages in thread

From: Nikolay Shaplov @ 2025-09-04 14:41 UTC (permalink / raw)

Introduce ternary reloption as a replacement for current `vacuum_truncate`
implementation. Remove `vacuum_truncate_set` additional flag and using
`TERNARY_UNSET` value instead.
---
 src/backend/access/common/reloptions.c | 129 ++++++++++++++++++++-----
 src/backend/commands/vacuum.c          |   4 +-
 src/include/access/reloptions.h        |  26 ++---
 src/include/c.h                        |  16 +++
 src/include/utils/rel.h                |   3 +-
 5 files changed, 135 insertions(+), 43 deletions(-)

inline patch 1: src/backend/access/common/reloptions.c, src/backend/commands/vacuum.c, src/include/access/reloptions.h and 2 more (+135/-43 lines) download inline-1.patchdiff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c index 0af3fea68f..f543f11001 100644 --- a/src/backend/access/common/reloptions.c +++ b/src/backend/access/common/reloptions.c @@ -40,9 +40,9 @@ * * To add an option: * - * (i) decide on a type (bool, integer, real, enum, string), name, default - * value, upper and lower bounds (if applicable); for strings, consider a - * validation routine. + * (i) decide on a type (bool, ternary, integer, real, enum, string), name, + * default value, upper and lower bounds (if applicable); for strings, + * consider a validation routine. * (ii) add a record below (or use add_<type>_reloption). * (iii) add it to the appropriate options struct (perhaps StdRdOptions) * (iv) add it to the appropriate handling routine (perhaps @@ -147,15 +147,6 @@ static relopt_bool boolRelOpts[] = }, false }, - { - { - "vacuum_truncate", - "Enables vacuum to truncate empty pages at the end of this table", - RELOPT_KIND_HEAP | RELOPT_KIND_TOAST, - ShareUpdateExclusiveLock - }, - true - }, { { "deduplicate_items", @@ -170,6 +161,21 @@ static relopt_bool boolRelOpts[] = {{NULL}} }; +static relopt_ternary ternaryRelOpts[] = +{ + { + { + "vacuum_truncate", + "Enables vacuum to truncate empty pages at the end of this table", + RELOPT_KIND_HEAP | RELOPT_KIND_TOAST, + ShareUpdateExclusiveLock + }, + TERNARY_UNSET + }, + /* list terminator */ + {{NULL}} +}; + static relopt_int intRelOpts[] = { { @@ -600,6 +606,13 @@ initialize_reloptions(void) boolRelOpts[i].gen.lockmode)); j++; } + for (i = 0; ternaryRelOpts[i].gen.name; i++) + { + Assert(DoLockModesConflict(ternaryRelOpts[i].gen.lockmode, + ternaryRelOpts[i].gen.lockmode)); + j++; + } + for (i = 0; intRelOpts[i].gen.name; i++) { Assert(DoLockModesConflict(intRelOpts[i].gen.lockmode, @@ -640,6 +653,14 @@ initialize_reloptions(void) j++; } + for (i = 0; ternaryRelOpts[i].gen.name; i++) + { + relOpts[j] = &ternaryRelOpts[i].gen; + relOpts[j]->type = RELOPT_TYPE_TERNARY; + relOpts[j]->namelen = strlen(relOpts[j]->name); + j++; + } + for (i = 0; intRelOpts[i].gen.name; i++) { relOpts[j] = &intRelOpts[i].gen; @@ -800,6 +821,9 @@ allocate_reloption(bits32 kinds, int type, const char *name, const char *desc, case RELOPT_TYPE_BOOL: size = sizeof(relopt_bool); break; + case RELOPT_TYPE_TERNARY: + size = sizeof(relopt_ternary); + break; case RELOPT_TYPE_INT: size = sizeof(relopt_int); break; @@ -883,6 +907,54 @@ add_local_bool_reloption(local_relopts *relopts, const char *name, add_local_reloption(relopts, (relopt_gen *) newoption, offset); } +/* + * init_ternary_reloption + * Allocate and initialize a new ternary reloption + */ +static relopt_ternary * +init_ternary_reloption(bits32 kinds, const char *name, const char *desc, + ternary default_val, LOCKMODE lockmode) +{ + relopt_ternary *newoption; + + newoption = (relopt_ternary *) allocate_reloption(kinds, + RELOPT_TYPE_TERNARY, name, desc, lockmode); + newoption->default_val = default_val; + + return newoption; +} + +/* + * add_ternary_reloption + * Add a new ternary reloption + */ +void +add_ternary_reloption(bits32 kinds, const char *name, const char *desc, + ternary default_val, LOCKMODE lockmode) +{ + relopt_ternary *newoption = init_ternary_reloption(kinds, name, desc, + default_val, lockmode); + + add_reloption((relopt_gen *) newoption); +} + +/* + * add_local_ternary_reloption + * Add a new ternary local reloption + * + * 'offset' is offset of ternary-typed field. + */ +void +add_local_ternary_reloption(local_relopts *relopts, const char *name, + const char *desc, ternary default_val, + int offset) +{ + relopt_ternary *newoption = init_ternary_reloption(RELOPT_KIND_LOCAL, + name, desc, + default_val, 0); + + add_local_reloption(relopts, (relopt_gen *) newoption, offset); +} /* * init_real_reloption @@ -1617,6 +1689,18 @@ parse_one_reloption(relopt_value *option, char *text_str, int text_len, option->gen->name, value))); } break; + case RELOPT_TYPE_TERNARY: + { + bool b; + parsed = parse_bool(value, &b); + option->values.ternary_val = b ? TERNARY_TRUE : TERNARY_FALSE; + if (validate && !parsed) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("invalid value for ternary option \"%s\": %s", + option->gen->name, value))); + } + break; case RELOPT_TYPE_INT: { relopt_int *optint = (relopt_int *) option->gen; @@ -1780,17 +1864,6 @@ fillRelOptions(void *rdopts, Size basesize, char *itempos = ((char *) rdopts) + elems[j].offset; char *string_val; - /* - * If isset_offset is provided, store whether the reloption is - * set there. - */ - if (elems[j].isset_offset > 0) - { - char *setpos = ((char *) rdopts) + elems[j].isset_offset; - - *(bool *) setpos = options[i].isset; - } - switch (options[i].gen->type) { case RELOPT_TYPE_BOOL: @@ -1798,6 +1871,11 @@ fillRelOptions(void *rdopts, Size basesize, options[i].values.bool_val : ((relopt_bool *) options[i].gen)->default_val; break; + case RELOPT_TYPE_TERNARY: + *(ternary *) itempos = options[i].isset ? + options[i].values.ternary_val : + ((relopt_ternary *) options[i].gen)->default_val; + break; case RELOPT_TYPE_INT: *(int *) itempos = options[i].isset ? options[i].values.int_val : @@ -1912,8 +1990,8 @@ default_reloptions(Datum reloptions, bool validate, relopt_kind kind) offsetof(StdRdOptions, parallel_workers)}, {"vacuum_index_cleanup", RELOPT_TYPE_ENUM, offsetof(StdRdOptions, vacuum_index_cleanup)}, - {"vacuum_truncate", RELOPT_TYPE_BOOL, - offsetof(StdRdOptions, vacuum_truncate), offsetof(StdRdOptions, vacuum_truncate_set)}, + {"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)} }; @@ -1993,7 +2071,6 @@ build_local_reloptions(local_relopts *relopts, Datum options, bool validate) elems[i].optname = opt->option->name; elems[i].opttype = opt->option->type; elems[i].offset = opt->offset; - elems[i].isset_offset = 0; /* not supported for local relopts yet */ i++; } diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c index 733ef40ae7..977babff54 100644 --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -2221,9 +2221,9 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params, { StdRdOptions *opts = (StdRdOptions *) rel->rd_options; - if (opts && opts->vacuum_truncate_set) + if (opts && opts->vacuum_truncate != TERNARY_UNSET) { - if (opts->vacuum_truncate) + if (opts->vacuum_truncate == TERNARY_TRUE) params.truncate = VACOPTVALUE_ENABLED; else params.truncate = VACOPTVALUE_DISABLED; diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h index a604a4702c..a436697658 100644 --- a/src/include/access/reloptions.h +++ b/src/include/access/reloptions.h @@ -29,6 +29,7 @@ typedef enum relopt_type { RELOPT_TYPE_BOOL, + RELOPT_TYPE_TERNARY, /* on, off, unset */ RELOPT_TYPE_INT, RELOPT_TYPE_REAL, RELOPT_TYPE_ENUM, @@ -80,6 +81,7 @@ typedef struct relopt_value union { bool bool_val; + ternary ternary_val; int int_val; double real_val; int enum_val; @@ -94,6 +96,12 @@ typedef struct relopt_bool bool default_val; } relopt_bool; +typedef struct relopt_rernary +{ + relopt_gen gen; + int default_val; +} relopt_ternary; + typedef struct relopt_int { relopt_gen gen; @@ -152,19 +160,6 @@ typedef struct const char *optname; /* option's name */ relopt_type opttype; /* option's datatype */ int offset; /* offset of field in result struct */ - - /* - * isset_offset is an optional offset of a field in the result struct that - * stores whether the option is explicitly set for the relation or if it - * just picked up the default value. In most cases, this can be - * accomplished by giving the reloption a special out-of-range default - * value (e.g., some integer reloptions use -2), but this isn't always - * possible. For example, a Boolean reloption cannot be given an - * out-of-range default, so we need another way to discover the source of - * its value. This offset is only used if given a value greater than - * zero. - */ - int isset_offset; } relopt_parse_elt; /* Local reloption definition */ @@ -195,6 +190,8 @@ typedef struct local_relopts extern relopt_kind add_reloption_kind(void); extern void add_bool_reloption(bits32 kinds, const char *name, const char *desc, bool default_val, LOCKMODE lockmode); +extern void add_ternary_reloption(bits32 kinds, const char *name, + const char *desc, int default_val, LOCKMODE lockmode); extern void add_int_reloption(bits32 kinds, const char *name, const char *desc, int default_val, int min_val, int max_val, LOCKMODE lockmode); @@ -214,6 +211,9 @@ extern void register_reloptions_validator(local_relopts *relopts, extern void add_local_bool_reloption(local_relopts *relopts, const char *name, const char *desc, bool default_val, int offset); +extern void add_local_ternary_reloption(local_relopts *relopts, + const char *name, const char *desc, + ternary default_val, int offset); extern void add_local_int_reloption(local_relopts *relopts, const char *name, const char *desc, int default_val, int min_val, int max_val, int offset); diff --git a/src/include/c.h b/src/include/c.h index 39022f8a9d..9c59ccccf8 100644 --- a/src/include/c.h +++ b/src/include/c.h @@ -488,6 +488,22 @@ typedef void (*pg_funcptr_t) (void); #include <stdbool.h> +/* + * ternary + * Boolean value with an extrea "unset" option + * + * Ternary data type is used in relation options that can be "true", "false" or + * "unset". Since relation options are used deep inside the PostgreSQL code, + * this type is declared globally. +*/ + +typedef enum ternary +{ + TERNARY_FALSE = 0, + TERNARY_TRUE = 1, + TERNARY_UNSET = -1 +} ternary; + /* ---------------------------------------------------------------- * Section 3: standard system types diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h index b552359915..7346d618f9 100644 --- a/src/include/utils/rel.h +++ b/src/include/utils/rel.h @@ -346,8 +346,7 @@ typedef struct StdRdOptions bool user_catalog_table; /* use as an additional catalog relation */ int parallel_workers; /* max number of parallel workers */ StdRdOptIndexCleanup vacuum_index_cleanup; /* controls index vacuuming */ - bool vacuum_truncate; /* enables vacuum to truncate a relation */ - bool vacuum_truncate_set; /* whether vacuum_truncate is set */ + ternary vacuum_truncate; /* enables vacuum to truncate a relation */ /* * Fraction of pages in a relation that vacuum can eagerly scan and fail -- 2.39.2
--nextPart4155600.3ZeAukHxDK Content-Disposition: attachment; filename="v2a-0003-Add-alias-to-be-used-as-unset-state.patch" Content-Transfer-Encoding: 7Bit Content-Type: text/x-patch; charset="unicode-2-0-utf-8"; name="v2a-0003-Add-alias-to-be-used-as-unset-state.patch" ^ permalink raw reply [nested|flat] 45+ messages in thread

* [PATCH v3] Introduce ternary reloptions
@ 2026-01-16 15:04  Álvaro Herrera <alvherre@kurilemu.de>
  0 siblings, 0 replies; 45+ messages in thread

From: Álvaro Herrera @ 2026-01-16 15:04 UTC (permalink / raw)

Introduce ternary reloption as a replacement for current
`vacuum_truncate` implementation. Remove the `vacuum_truncate_set`
separate flag and use TERNARY_UNSET instead.

This could also be used for other options such as `vacuum_index_cleanup`
and `buffering`, but lets get the scaffolding in first.

Discussion: https://postgr.es/m/3474141.usfYGdeWWP@thinkpad-pgpro
---
 src/backend/access/common/reloptions.c   | 137 ++++++++++++++++++-----
 src/backend/commands/vacuum.c            |   4 +-
 src/include/access/reloptions.h          |  27 ++---
 src/include/postgres.h                   |  15 +++
 src/include/utils/rel.h                  |   3 +-
 src/test/regress/expected/reloptions.out |  36 ++++++
 src/test/regress/sql/reloptions.sql      |  21 ++++
 src/tools/pgindent/typedefs.list         |   2 +
 8 files changed, 202 insertions(+), 43 deletions(-)

inline patch 1: src/backend/access/common/reloptions.c, src/backend/commands/vacuum.c, src/include/access/reloptions.h and 5 more (+202/-43 lines) download inline-1.patchdiff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c index 0b83f98ed5f..6f1f577581a 100644 --- a/src/backend/access/common/reloptions.c +++ b/src/backend/access/common/reloptions.c @@ -40,9 +40,9 @@ * * To add an option: * - * (i) decide on a type (bool, integer, real, enum, string), name, default - * value, upper and lower bounds (if applicable); for strings, consider a - * validation routine. + * (i) decide on a type (bool, ternary, integer, real, enum, string), name, + * default value, upper and lower bounds (if applicable); for strings, + * consider a validation routine. * (ii) add a record below (or use add_<type>_reloption). * (iii) add it to the appropriate options struct (perhaps StdRdOptions) * (iv) add it to the appropriate handling routine (perhaps @@ -147,15 +147,6 @@ static relopt_bool boolRelOpts[] = }, false }, - { - { - "vacuum_truncate", - "Enables vacuum to truncate empty pages at the end of this table", - RELOPT_KIND_HEAP | RELOPT_KIND_TOAST, - ShareUpdateExclusiveLock - }, - true - }, { { "deduplicate_items", @@ -170,6 +161,25 @@ static relopt_bool boolRelOpts[] = {{NULL}} }; +static relopt_ternary ternaryRelOpts[] = +{ + { + { + "vacuum_truncate", + "Enables vacuum to truncate empty pages at the end of this table", + RELOPT_KIND_HEAP | RELOPT_KIND_TOAST, + ShareUpdateExclusiveLock + }, + TERNARY_UNSET + }, + /* list terminator */ + { + { + NULL + } + } +}; + static relopt_int intRelOpts[] = { { @@ -609,6 +619,13 @@ initialize_reloptions(void) boolRelOpts[i].gen.lockmode)); j++; } + for (i = 0; ternaryRelOpts[i].gen.name; i++) + { + Assert(DoLockModesConflict(ternaryRelOpts[i].gen.lockmode, + ternaryRelOpts[i].gen.lockmode)); + j++; + } + for (i = 0; intRelOpts[i].gen.name; i++) { Assert(DoLockModesConflict(intRelOpts[i].gen.lockmode, @@ -649,6 +666,14 @@ initialize_reloptions(void) j++; } + for (i = 0; ternaryRelOpts[i].gen.name; i++) + { + relOpts[j] = &ternaryRelOpts[i].gen; + relOpts[j]->type = RELOPT_TYPE_TERNARY; + relOpts[j]->namelen = strlen(relOpts[j]->name); + j++; + } + for (i = 0; intRelOpts[i].gen.name; i++) { relOpts[j] = &intRelOpts[i].gen; @@ -809,6 +834,9 @@ allocate_reloption(bits32 kinds, int type, const char *name, const char *desc, case RELOPT_TYPE_BOOL: size = sizeof(relopt_bool); break; + case RELOPT_TYPE_TERNARY: + size = sizeof(relopt_ternary); + break; case RELOPT_TYPE_INT: size = sizeof(relopt_int); break; @@ -892,6 +920,57 @@ add_local_bool_reloption(local_relopts *relopts, const char *name, add_local_reloption(relopts, (relopt_gen *) newoption, offset); } +/* + * init_ternary_reloption + * Allocate and initialize a new ternary reloption + */ +static relopt_ternary * +init_ternary_reloption(bits32 kinds, const char *name, const char *desc, + pg_ternary default_val, LOCKMODE lockmode) +{ + relopt_ternary *newoption; + + newoption = (relopt_ternary *) + allocate_reloption(kinds, RELOPT_TYPE_TERNARY, name, desc, lockmode); + newoption->default_val = default_val; + + return newoption; +} + +/* + * add_ternary_reloption + * Add a new ternary reloption + */ +void +add_ternary_reloption(bits32 kinds, const char *name, const char *desc, + pg_ternary default_val, LOCKMODE lockmode) +{ + relopt_ternary *newoption; + + newoption = + init_ternary_reloption(kinds, name, desc, default_val, lockmode); + + add_reloption((relopt_gen *) newoption); +} + +/* + * add_local_ternary_reloption + * Add a new ternary local reloption + * + * 'offset' is offset of ternary-typed field. + */ +void +add_local_ternary_reloption(local_relopts *relopts, const char *name, + const char *desc, pg_ternary default_val, + int offset) +{ + relopt_ternary *newoption; + + newoption = + init_ternary_reloption(RELOPT_KIND_LOCAL, name, desc, default_val, 0); + + add_local_reloption(relopts, (relopt_gen *) newoption, offset); +} /* * init_real_reloption @@ -1626,6 +1705,19 @@ parse_one_reloption(relopt_value *option, char *text_str, int text_len, option->gen->name, value))); } break; + case RELOPT_TYPE_TERNARY: + { + bool b; + + parsed = parse_bool(value, &b); + option->values.ternary_val = b ? TERNARY_TRUE : TERNARY_FALSE; + if (validate && !parsed) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("invalid value for ternary option \"%s\": %s", + option->gen->name, value))); + } + break; case RELOPT_TYPE_INT: { relopt_int *optint = (relopt_int *) option->gen; @@ -1789,17 +1881,6 @@ fillRelOptions(void *rdopts, Size basesize, char *itempos = ((char *) rdopts) + elems[j].offset; char *string_val; - /* - * If isset_offset is provided, store whether the reloption is - * set there. - */ - if (elems[j].isset_offset > 0) - { - char *setpos = ((char *) rdopts) + elems[j].isset_offset; - - *(bool *) setpos = options[i].isset; - } - switch (options[i].gen->type) { case RELOPT_TYPE_BOOL: @@ -1807,6 +1888,11 @@ fillRelOptions(void *rdopts, Size basesize, options[i].values.bool_val : ((relopt_bool *) options[i].gen)->default_val; break; + case RELOPT_TYPE_TERNARY: + *(pg_ternary *) itempos = options[i].isset ? + options[i].values.ternary_val : + ((relopt_ternary *) options[i].gen)->default_val; + break; case RELOPT_TYPE_INT: *(int *) itempos = options[i].isset ? options[i].values.int_val : @@ -1923,8 +2009,8 @@ default_reloptions(Datum reloptions, bool validate, relopt_kind kind) offsetof(StdRdOptions, parallel_workers)}, {"vacuum_index_cleanup", RELOPT_TYPE_ENUM, offsetof(StdRdOptions, vacuum_index_cleanup)}, - {"vacuum_truncate", RELOPT_TYPE_BOOL, - offsetof(StdRdOptions, vacuum_truncate), offsetof(StdRdOptions, vacuum_truncate_set)}, + {"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)} }; @@ -2004,7 +2090,6 @@ build_local_reloptions(local_relopts *relopts, Datum options, bool validate) elems[i].optname = opt->option->name; elems[i].opttype = opt->option->type; elems[i].offset = opt->offset; - elems[i].isset_offset = 0; /* not supported for local relopts yet */ i++; } diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c index aa4fbec143f..696eab9bd97 100644 --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -2224,9 +2224,9 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params, { StdRdOptions *opts = (StdRdOptions *) rel->rd_options; - if (opts && opts->vacuum_truncate_set) + if (opts && opts->vacuum_truncate != TERNARY_UNSET) { - if (opts->vacuum_truncate) + if (opts->vacuum_truncate == TERNARY_TRUE) params.truncate = VACOPTVALUE_ENABLED; else params.truncate = VACOPTVALUE_DISABLED; diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h index 2f08e1b0cf0..dfbef2babf2 100644 --- a/src/include/access/reloptions.h +++ b/src/include/access/reloptions.h @@ -29,6 +29,7 @@ typedef enum relopt_type { RELOPT_TYPE_BOOL, + RELOPT_TYPE_TERNARY, /* on, off, unset */ RELOPT_TYPE_INT, RELOPT_TYPE_REAL, RELOPT_TYPE_ENUM, @@ -80,6 +81,7 @@ typedef struct relopt_value union { bool bool_val; + pg_ternary ternary_val; int int_val; double real_val; int enum_val; @@ -94,6 +96,12 @@ typedef struct relopt_bool bool default_val; } relopt_bool; +typedef struct relopt_ternary +{ + relopt_gen gen; + int default_val; +} relopt_ternary; + typedef struct relopt_int { relopt_gen gen; @@ -152,19 +160,6 @@ typedef struct const char *optname; /* option's name */ relopt_type opttype; /* option's datatype */ int offset; /* offset of field in result struct */ - - /* - * isset_offset is an optional offset of a field in the result struct that - * stores whether the option is explicitly set for the relation or if it - * just picked up the default value. In most cases, this can be - * accomplished by giving the reloption a special out-of-range default - * value (e.g., some integer reloptions use -2), but this isn't always - * possible. For example, a Boolean reloption cannot be given an - * out-of-range default, so we need another way to discover the source of - * its value. This offset is only used if given a value greater than - * zero. - */ - int isset_offset; } relopt_parse_elt; /* Local reloption definition */ @@ -195,6 +190,9 @@ typedef struct local_relopts extern relopt_kind add_reloption_kind(void); extern void add_bool_reloption(bits32 kinds, const char *name, const char *desc, bool default_val, LOCKMODE lockmode); +extern void add_ternary_reloption(bits32 kinds, const char *name, + const char *desc, pg_ternary default_val, + LOCKMODE lockmode); extern void add_int_reloption(bits32 kinds, const char *name, const char *desc, int default_val, int min_val, int max_val, LOCKMODE lockmode); @@ -214,6 +212,9 @@ extern void register_reloptions_validator(local_relopts *relopts, extern void add_local_bool_reloption(local_relopts *relopts, const char *name, const char *desc, bool default_val, int offset); +extern void add_local_ternary_reloption(local_relopts *relopts, + const char *name, const char *desc, + pg_ternary default_val, int offset); extern void add_local_int_reloption(local_relopts *relopts, const char *name, const char *desc, int default_val, int min_val, int max_val, int offset); diff --git a/src/include/postgres.h b/src/include/postgres.h index 7d93fbce709..47c88780776 100644 --- a/src/include/postgres.h +++ b/src/include/postgres.h @@ -543,6 +543,21 @@ Float8GetDatum(float8 X) * ---------------------------------------------------------------- */ +/* + * pg_ternary + * Boolean value with an extra "unset" value + * + * This enum can be used for values that want to distinguish between true, + * false, and unset. +*/ + +typedef enum pg_ternary +{ + TERNARY_FALSE = 0, + TERNARY_TRUE = 1, + TERNARY_UNSET = -1 +} pg_ternary; + /* * NON_EXEC_STATIC: It's sometimes useful to define a variable or function * that is normally static but extern when using EXEC_BACKEND (see diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h index d03ab247788..5feb18a5373 100644 --- a/src/include/utils/rel.h +++ b/src/include/utils/rel.h @@ -347,8 +347,7 @@ typedef struct StdRdOptions bool user_catalog_table; /* use as an additional catalog relation */ int parallel_workers; /* max number of parallel workers */ StdRdOptIndexCleanup vacuum_index_cleanup; /* controls index vacuuming */ - bool vacuum_truncate; /* enables vacuum to truncate a relation */ - bool vacuum_truncate_set; /* whether vacuum_truncate is set */ + pg_ternary vacuum_truncate; /* enables vacuum to truncate a relation */ /* * Fraction of pages in a relation that vacuum can eagerly scan and fail diff --git a/src/test/regress/expected/reloptions.out b/src/test/regress/expected/reloptions.out index 9de19b4e3f1..1c99f79ab01 100644 --- a/src/test/regress/expected/reloptions.out +++ b/src/test/regress/expected/reloptions.out @@ -98,6 +98,42 @@ SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; {fillfactor=13,autovacuum_enabled=false} (1 row) +-- Tests for future (FIXME) ternary options +-- behave as boolean option: accept unassigned name and truncated value +DROP TABLE reloptions_test; +CREATE TABLE reloptions_test(i INT) WITH (vacuum_truncate); +SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; + reloptions +------------------------ + {vacuum_truncate=true} +(1 row) + +DROP TABLE reloptions_test; +CREATE TABLE reloptions_test(i INT) WITH (vacuum_truncate=FaLS); +SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; + reloptions +------------------------ + {vacuum_truncate=fals} +(1 row) + +-- preferred "true" alias is used when storing +DROP TABLE reloptions_test; +CREATE TABLE reloptions_test(i INT) WITH (vacuum_index_cleanup=on); +SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; + reloptions +--------------------------- + {vacuum_index_cleanup=on} +(1 row) + +-- custom "third" value is available +DROP TABLE reloptions_test; +CREATE TABLE reloptions_test(i INT) WITH (vacuum_index_cleanup=auto); +SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; + reloptions +----------------------------- + {vacuum_index_cleanup=auto} +(1 row) + -- Test vacuum_truncate option DROP TABLE reloptions_test; CREATE TEMP TABLE reloptions_test(i INT NOT NULL, j text) diff --git a/src/test/regress/sql/reloptions.sql b/src/test/regress/sql/reloptions.sql index 24fbe0b478d..f5980dafcbc 100644 --- a/src/test/regress/sql/reloptions.sql +++ b/src/test/regress/sql/reloptions.sql @@ -59,6 +59,27 @@ UPDATE pg_class ALTER TABLE reloptions_test RESET (illegal_option); SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; +-- Tests for future (FIXME) ternary options + +-- behave as boolean option: accept unassigned name and truncated value +DROP TABLE reloptions_test; +CREATE TABLE reloptions_test(i INT) WITH (vacuum_truncate); +SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; + +DROP TABLE reloptions_test; +CREATE TABLE reloptions_test(i INT) WITH (vacuum_truncate=FaLS); +SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; + +-- preferred "true" alias is used when storing +DROP TABLE reloptions_test; +CREATE TABLE reloptions_test(i INT) WITH (vacuum_index_cleanup=on); +SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; + +-- custom "third" value is available +DROP TABLE reloptions_test; +CREATE TABLE reloptions_test(i INT) WITH (vacuum_index_cleanup=auto); +SELECT reloptions FROM pg_class WHERE oid = 'reloptions_test'::regclass; + -- Test vacuum_truncate option DROP TABLE reloptions_test; diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 3f3a888fd0e..1c8610fd46c 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3952,6 +3952,7 @@ pg_sha512_ctx pg_snapshot pg_special_case pg_stack_base_t +pg_ternary pg_time_t pg_time_usec_t pg_tz @@ -4079,6 +4080,7 @@ relopt_kind relopt_parse_elt relopt_real relopt_string +relopt_ternary relopt_type relopt_value relopts_validator -- 2.47.3
--c36t3fndxhkzbl5z-- ^ permalink raw reply [nested|flat] 45+ messages in thread

* [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-04-13 08:21  JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 2 replies; 45+ messages in thread

From: JoongHyuk Shin @ 2026-04-13 08:21 UTC (permalink / raw)
  To: pgsql-hackers@lists.postgresql.org

The five recovery target GUC assign hooks -- assign_recovery_target,
assign_recovery_target_lsn, assign_recovery_target_name,
assign_recovery_target_time, and assign_recovery_target_xid -- all call
error_multiple_recovery_targets() when a second conflicting target is
detected, which invokes ereport(ERROR).  The GUC README
(src/backend/utils/misc/README) is explicit: "There is no provision for
a failure result code. assign_hooks should never fail."  Raising an
error from an assign hook leaves guc.c's internal state inconsistent
before the abort, because the abort handling path was not designed to
run mid-assign.

The code acknowledges this with an XXX comment that has been there for
years:

    XXX this code is broken by design.  Throwing an error from a GUC
    assign hook breaks fundamental assumptions of guc.c.  So long as
    all the variables for which this can happen are PGC_POSTMASTER, the
    consequences are limited, since we'd just abort postmaster startup
    anyway.  Nonetheless it's likely that we have odd behaviors such as
    unexpected GUC ordering dependencies.

The "limited consequences" argument is true enough that this hasn't
caused visible failures in practice, but fixing a known contract
violation seems worthwhile.

The fix is to remove the conflict check from all five assign hooks and
detect conflicts in validateRecoveryParameters() instead.  That
function already runs after all GUCs have been loaded (called from
InitWalRecovery() in the startup process), so it can safely read each
GUC's current value via GetConfigOption() and count how many are
non-empty.  If more than one is set, it reports FATAL, consistent with
the other validation errors already in that function.

This changes when the error fires: it now happens in the startup process
rather than in the postmaster's ProcessConfigFile.  The outcome is the
same (server does not start), but guc.c's state is no longer disturbed.

There is one secondary behavioral change: when recovery is not actually
requested (ArchiveRecoveryRequested is false), validateRecoveryParameters
returns early and never checks for conflicts, so conflicting recovery
target settings are silently ignored.  The old code would reject them
even then, since assign hooks fire unconditionally during ProcessConfigFile.
I think the new behavior is arguably more correct -- those GUCs have no
effect when recovery is not requested, so there is no reason to treat
their values as an error.  The existing TAP test in 003_recovery_targets.pl
already covers the conflict-in-recovery case; this patch adds a test for
the new behavior (conflicting targets accepted outside recovery).

Patch attached.

Attachments:

0001-Don-t-call-ereport-ERROR-from-recovery-target-GUC-as.patch (application/octet-stream, 8.9K) download From a4be55d99de64e47c368f7512d47164803467a03 Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Mon, 13 Apr 2026 16:52:26 +0900 Subject: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks The five recovery target GUC assign hooks (assign_recovery_target, assign_recovery_target_lsn, assign_recovery_target_name, assign_recovery_target_time, assign_recovery_target_xid) all called error_multiple_recovery_targets(), which invoked ereport(ERROR). The GUC README explicitly states that assign hooks must never fail; raising an error from an assign hook leaves guc.c's internal state inconsistent before the abort. A comment in the code itself acknowledged this as "broken by design." Fix this by removing the conflict check from all five assign hooks and detecting multiple recovery targets in validateRecoveryParameters() instead. That function already runs after all GUCs have been loaded (called from InitWalRecovery() in the startup process), so it can read each GUC's current value via GetConfigOption() and count how many are non-empty. If more than one is set, it reports FATAL, which is consistent with the other validation errors in that function. This changes the timing of the error: it now fires in the startup process rather than in the postmaster's ProcessConfigFile. The outcome is the same (server does not start), and the GUC infrastructure is left in a consistent state. A secondary behavioral change: conflicting recovery target settings are now silently ignored when ArchiveRecoveryRequested is false (i.e., when recovery is not actually requested). The previous code would reject them even in normal startup, which was unnecessary since those GUCs have no effect when recovery is not requested. A TAP test is added for this new behavior. --- src/backend/access/transam/xlogrecovery.c | 87 +++++++++++---------- src/test/recovery/t/003_recovery_targets.pl | 22 ++++++ 2 files changed, 66 insertions(+), 43 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index c236e2b7969..65eeb1214df 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -1070,6 +1070,43 @@ validateRecoveryParameters(void) if (!ArchiveRecoveryRequested) return; + /* + * Check for conflicting recovery targets. We do this here rather than in + * the GUC assign hooks because throwing an error from an assign hook + * violates guc.c's contract. By the time we reach this function, all + * GUCs have been loaded, so we can safely read their current values. + */ + { + int ntargets = 0; + const char *val; + + val = GetConfigOption("recovery_target", true, false); + if (val != NULL && val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_lsn", true, false); + if (val != NULL && val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_name", true, false); + if (val != NULL && val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_time", true, false); + if (val != NULL && val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_xid", true, false); + if (val != NULL && val[0] != '\0') + ntargets++; + + if (ntargets > 1) + ereport(FATAL, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("multiple recovery targets specified"), + errdetail("At most one of \"recovery_target\", " + "\"recovery_target_lsn\", " + "\"recovery_target_name\", " + "\"recovery_target_time\", " + "\"recovery_target_xid\" may be set."))); + } + /* * Check for compulsory parameters */ @@ -4765,31 +4802,15 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) /* * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable - * recoveryTarget tracks which kind of recovery target was chosen. Other - * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. - * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * may be set. The global variable recoveryTarget tracks which kind of + * recovery target was chosen. Other variables store the actual target value + * (for example a string or a xid). We want to allow setting the same + * parameter multiple times, and we want to allow unsetting a parameter and + * setting a different one, so we unset recoveryTarget when the parameter is + * set to an empty string. Conflicts between multiple non-empty settings are + * detected in validateRecoveryParameters(). */ -pg_noreturn static void -error_multiple_recovery_targets(void) -{ - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); -} - /* * GUC check_hook for recovery_target */ @@ -4810,10 +4831,6 @@ check_recovery_target(char **newval, void **extra, GucSource source) void assign_recovery_target(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_IMMEDIATE; else @@ -4851,10 +4868,6 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_LSN; @@ -4886,10 +4899,6 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) void assign_recovery_target_name(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_NAME; @@ -4966,10 +4975,6 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) void assign_recovery_target_time(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_TIME; else @@ -5094,10 +5099,6 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_XID; diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..4014c28bf95 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -190,6 +190,28 @@ like( qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicting recovery targets without recovery mode +# +# When recovery is not requested (no recovery.signal), conflicting recovery +# target settings should be silently accepted, since those GUCs have no +# effect outside of recovery. +$node_primary->stop; +$node_primary->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); +$node_primary->start; +my $primary_pid = $node_primary->safe_psql('postgres', + 'SELECT pg_backend_pid()'); +ok(defined $primary_pid && $primary_pid ne '', + 'server starts despite conflicting recovery targets outside recovery'); + +# Clean up the conflicting settings before continuing +$node_primary->safe_psql('postgres', + "ALTER SYSTEM RESET recovery_target_name"); +$node_primary->safe_psql('postgres', + "ALTER SYSTEM RESET recovery_target_time"); +$node_primary->restart; + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.52.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-04-24 01:10  Greg Lamberson <greg@lamco.io>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  1 sibling, 0 replies; 45+ messages in thread

From: Greg Lamberson @ 2026-04-24 01:10 UTC (permalink / raw)
  To: pgsql-hackers@lists.postgresql.org; +Cc: sjh910805@gmail.com

Hi Shin,

Thanks for taking this one on.  The XXX comment has been there for a
long time and fixing a known contract violation is worthwhile.  The
mechanical part of the patch, moving the conflict check from the
assign hooks into validateRecoveryParameters(), looks right to me.

A few things worth discussing before this gets a committer's eye:

1. Behavioral change when recovery is not requested.

The patch's commit message notes that conflicting recovery target
settings are now silently accepted when ArchiveRecoveryRequested is
false, and argues this is "arguably more correct" because those GUCs
have no effect outside recovery.  I am not sure I agree.  Today a
misconfigured postgresql.conf (say, both recovery_target_time and
recovery_target_xid present) is caught immediately at postmaster
start.  After the patch, that same misconfiguration boots
successfully, and the operator only finds out later when they add
recovery.signal and the startup process FATALs.  That is a real
downgrade in error-detection timing.

Is it feasible to keep the early-detection behavior without violating
the assign-hook contract?  One option: emit a WARNING (not ERROR)
from the assign hook, or defer the check to a post-config-file pass
(GUC_check_hook chain, or a one-shot check in PostmasterMain before
the startup process is forked).  I do not have a strong opinion on
the mechanism, but I think the user-visible behavior (conflicting
recovery_target_* settings are caught at server start) is worth
preserving if possible.

If preserving that is not feasible, I think the commit message should
flag the timing change more prominently, since it is a user-visible
change to when postgres refuses to start.

2. Test coverage of the conflict-in-recovery path.

The existing 003_recovery_targets.pl has a test (not modified by
this patch, as far as I can tell) that exercises the multiple-targets
rejection.  With v1, that rejection fires from
validateRecoveryParameters() rather than from an assign hook, so the
error message source changes even if the text is identical.  CFBot
is green, so the existing regex must still match, but it would be
good to confirm which test covers this and to verify the test's
assertions actually validate the new FATAL path, not just "server
failed to start for some reason".

3. errdetail wording.

    errdetail("At most one of \"recovery_target\", ..., "
              "\"recovery_target_xid\" may be set.")

The error-message style guide (doc/src/sgml/error-style-guide.sgml)
says to avoid "may" because it reads as permission rather than
ability.  Suggest "can be set" instead.  Trivial, but since we are
already touching this message.

4. Test cleanup fragility.

The new test case does:

    $node_primary->append_conf('postgresql.conf', "recovery_target_name = ...
                               recovery_target_time = ...");
    $node_primary->start;
    ...
    ALTER SYSTEM RESET recovery_target_name;
    ALTER SYSTEM RESET recovery_target_time;
    $node_primary->restart;

append_conf permanently extends postgresql.conf.  The ALTER SYSTEM
RESET writes to postgresql.auto.conf which takes precedence, so the
stale postgresql.conf lines are effectively masked for subsequent
test steps, but they remain in the file, and any later test that
relies on a predictable postgresql.conf content (for example a test
that inspects ConfigFileVar or does its own append) could be
confused.  Using ALTER SYSTEM SET for the setup would be cleaner,
or using a dedicated temporary cluster for this one case.  Also,
the ok() predicate `defined $primary_pid && $primary_pid ne ''` is
redundant.  safe_psql would have died earlier on start failure.

5. Nit: block comment.

The new block inside validateRecoveryParameters() could benefit from
noting that ArchiveRecoveryRequested is guaranteed true at this
point (because of the early return above it), so a reader does not
have to scroll up to confirm.

Nothing here is a blocker.  I think the overall direction is right.
The behavioral change question in item 1 is the main design decision
I would want the author's thinking on.

Thanks,
Greg


^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-04-24 13:08  Fujii Masao <masao.fujii@gmail.com>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  1 sibling, 1 reply; 45+ messages in thread

From: Fujii Masao @ 2026-04-24 13:08 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org

On Mon, Apr 13, 2026 at 5:21 PM JoongHyuk Shin <sjh910805@gmail.com> wrote:
> The "limited consequences" argument is true enough that this hasn't
> caused visible failures in practice, but fixing a known contract
> violation seems worthwhile.

+1


> The fix is to remove the conflict check from all five assign hooks and
> detect conflicts in validateRecoveryParameters() instead.  That
> function already runs after all GUCs have been loaded (called from
> InitWalRecovery() in the startup process), so it can safely read each
> GUC's current value via GetConfigOption() and count how many are
> non-empty.  If more than one is set, it reports FATAL, consistent with
> the other validation errors already in that function.

In the master, when the following two recovery targets are specified,
the recovery target assign hook detects that multiple targets were given
and reports an error. With the patch, however, the same settings do not
raise an error, recoveryTarget is set to RECOVERY_TARGET_UNSET, and
recovery unexpectedly proceeds with no target. Could this be a bug
in the patch?

    recovery_target_xid = '9999'
    recovery_target_time = ''

Regards,

-- 
Fujii Masao





^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-04-27 01:52  Michael Paquier <michael@paquier.xyz>
  parent: Fujii Masao <masao.fujii@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Michael Paquier @ 2026-04-27 01:52 UTC (permalink / raw)
  To: Fujii Masao <masao.fujii@gmail.com>; +Cc: JoongHyuk Shin <sjh910805@gmail.com>; pgsql-hackers@lists.postgresql.org

On Fri, Apr 24, 2026 at 10:08:04PM +0900, Fujii Masao wrote:
> In the master, when the following two recovery targets are specified,
> the recovery target assign hook detects that multiple targets were given
> and reports an error. With the patch, however, the same settings do not
> raise an error, recoveryTarget is set to RECOVERY_TARGET_UNSET, and
> recovery unexpectedly proceeds with no target. Could this be a bug
> in the patch?
> 
>     recovery_target_xid = '9999'
>     recovery_target_time = ''

Don't think so.  You are specifying for recovery_target_time the same
thing as the default, as in "I don't know and do nothing about the
time".  Why would it matter to make the difference between a default
value set and what's stored by default if nothing is set in this case?

FWIW, I am wondering if we should seriously consider this stuff as
candidate for a backpatch because this is a design mistake: we should
never *ever* rely on the GUC hooks to do cross-checks of multiple
values, f2cbffc7a618 deciding that it was a right thing to do.  It's
not.  The risk of breaking something may not justify that a backpatch.

+1 for reworking that on HEAD, at least.
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-04-27 05:36  Fujii Masao <masao.fujii@gmail.com>
  parent: Michael Paquier <michael@paquier.xyz>
  0 siblings, 1 reply; 45+ messages in thread

From: Fujii Masao @ 2026-04-27 05:36 UTC (permalink / raw)
  To: Michael Paquier <michael@paquier.xyz>; +Cc: JoongHyuk Shin <sjh910805@gmail.com>; pgsql-hackers@lists.postgresql.org

On Mon, Apr 27, 2026 at 10:52 AM Michael Paquier <michael@paquier.xyz> wrote:
>
> On Fri, Apr 24, 2026 at 10:08:04PM +0900, Fujii Masao wrote:
> > In the master, when the following two recovery targets are specified,
> > the recovery target assign hook detects that multiple targets were given
> > and reports an error. With the patch, however, the same settings do not
> > raise an error, recoveryTarget is set to RECOVERY_TARGET_UNSET, and
> > recovery unexpectedly proceeds with no target. Could this be a bug
> > in the patch?
> >
> >     recovery_target_xid = '9999'
> >     recovery_target_time = ''
>
> Don't think so.  You are specifying for recovery_target_time the same
> thing as the default, as in "I don't know and do nothing about the
> time".  Why would it matter to make the difference between a default
> value set and what's stored by default if nothing is set in this case?

With those settings, how should recovery behave? I would expect it to
behave as in master, i.e., detect that multiple targets were specified
and report an error. Alternatively, it might be OK for me to proceed
with recovery_target_xid = '9999' and ignore recovery_target_time = '',
since that matches the default.

With the proposed patch, however, both settings are ignored and
recovery starts with no target. That seems unexpected to me.


> +1 for reworking that on HEAD, at least.

I was thinking the same. +1

Regards,

-- 
Fujii Masao





^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-04-27 05:59  Michael Paquier <michael@paquier.xyz>
  parent: Fujii Masao <masao.fujii@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Michael Paquier @ 2026-04-27 05:59 UTC (permalink / raw)
  To: Fujii Masao <masao.fujii@gmail.com>; +Cc: JoongHyuk Shin <sjh910805@gmail.com>; pgsql-hackers@lists.postgresql.org

On Mon, Apr 27, 2026 at 02:36:11PM +0900, Fujii Masao wrote:
> With the proposed patch, however, both settings are ignored and
> recovery starts with no target. That seems unexpected to me.

If that's the case (not tested myself), agreed.
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-04-29 09:29  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Michael Paquier <michael@paquier.xyz>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-04-29 09:29 UTC (permalink / raw)
  To: Michael Paquier <michael@paquier.xyz>; +Cc: Fujii Masao <masao.fujii@gmail.com>; pgsql-hackers@lists.postgresql.org

Thanks for the reviews.

v2 attached.

* Conflict check moved to a new static CheckRecoveryTargetConflicts(),
  called from validateRecoveryParameters() before its early return.
  It runs at every startup, so misconfiguration is caught as in master.
  I kept it in startup process rather than PostmasterMain
(Greg'ssuggestion),
  matching the existing recovery validation there.

* Removed each assign hook's `else recoveryTarget = UNSET` branch
  (B in Fujii's framing).  Fixes the empty-string clobber Fujii reported,
  `recovery_target_xid='9999' + recovery_target_time=''` was silently
  running with no target.
  003_recovery_targets.pl now covers it (fails on v1, passes on v2).

* errdetail "may" -> "can" (Greg).

* TAP test that asserted the v1 regression is replaced with one
  asserting conflict rejection at every startup.

Agreed: HEAD only, no backpatch.

--
JH Shin


On Mon, Apr 27, 2026 at 3:00 PM Michael Paquier <michael@paquier.xyz> wrote:

> On Mon, Apr 27, 2026 at 02:36:11PM +0900, Fujii Masao wrote:
> > With the proposed patch, however, both settings are ignored and
> > recovery starts with no target. That seems unexpected to me.
>
> If that's the case (not tested myself), agreed.
> --
> Michael
>

Attachments:

v2-0001-Don-t-call-ereport-ERROR-from-recovery-target-GUC.patch (application/octet-stream, 12.4K) download From 2676f4ad037fec97be49dd4099b65bd8cf569119 Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Wed, 29 Apr 2026 15:32:21 +0900 Subject: [PATCH v2] Don't call ereport(ERROR) from recovery target GUC assign hooks The five recovery target GUC assign hooks (assign_recovery_target, assign_recovery_target_lsn, assign_recovery_target_name, assign_recovery_target_time, assign_recovery_target_xid) all called error_multiple_recovery_targets(), which invoked ereport(ERROR). The GUC README explicitly states that assign hooks must never fail; raising an error from an assign hook leaves guc.c's internal state inconsistent before the abort. A comment in the code itself acknowledged this as "broken by design." Fix this by removing the conflict check from all five assign hooks and detecting multiple recovery targets in a new CheckRecoveryTargetConflicts() function, called from validateRecoveryParameters() before its early return for !ArchiveRecoveryRequested. The check therefore runs at every startup regardless of recovery mode, preserving the existing behavior of detecting misconfiguration at server start time (rather than only when recovery.signal is added). Also remove the unconditional `recoveryTarget = RECOVERY_TARGET_UNSET` fallback in each assign hook's empty-string branch. An empty string is the GUCs' default value and is now treated as a no-op rather than as an explicit "unset all targets" signal. This fixes a corner case where setting one GUC to a value and another to an empty string would silently clobber the first GUC's target, leaving recovery without any target. This corner case was reported by Fujii Masao during v1 review. Reported-by: Fujii Masao Discussion: https://postgr.es/m/CACSdjfPUa4UvKjADgOERXoxNYmCg2mqqiqKkiJk6mX6E4qgVFw@mail.gmail.com --- src/backend/access/transam/xlogrecovery.c | 118 +++++++++++--------- src/test/recovery/t/003_recovery_targets.pl | 48 +++++++- 2 files changed, 111 insertions(+), 55 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index c236e2b7969..5cabd079d57 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -341,6 +341,7 @@ static void ApplyWalRecord(XLogReaderState *xlogreader, XLogRecord *record, Time static void EnableStandbyMode(void); static void readRecoverySignalFile(void); static void validateRecoveryParameters(void); +static void CheckRecoveryTargetConflicts(void); static bool read_backup_label(XLogRecPtr *checkPointLoc, TimeLineID *backupLabelTLI, bool *backupEndRequired, bool *backupFromStandby); @@ -1067,6 +1068,8 @@ readRecoverySignalFile(void) static void validateRecoveryParameters(void) { + CheckRecoveryTargetConflicts(); + if (!ArchiveRecoveryRequested) return; @@ -1144,6 +1147,62 @@ validateRecoveryParameters(void) } } +/* + * CheckRecoveryTargetConflicts + * + * Validate that at most one of the recovery_target_* GUCs is set to a + * non-empty value. This is called from validateRecoveryParameters() at every + * server startup, regardless of whether archive recovery is requested, so + * misconfiguration is detected at server start time rather than later when + * recovery.signal is added. + * + * The check is a separate function rather than inlined into + * validateRecoveryParameters() because it intentionally runs even when no + * recovery is requested, while the rest of validateRecoveryParameters() is + * recovery-mode-only. Keeping it as a named function makes that separation + * explicit. + * + * The check used to live in the assign hooks of the recovery_target_* GUCs + * (calling ereport(ERROR) on conflict), which violated guc.c's contract that + * assign hooks must never fail. Moving the check here keeps the assign hooks + * contract-compliant. + * + * If a future patch adds a sixth recovery_target_* GUC, both this list and + * the errdetail below must be updated. + */ +static void +CheckRecoveryTargetConflicts(void) +{ + int ntargets = 0; + const char *val; + + val = GetConfigOption("recovery_target", false, false); + if (val != NULL && val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_lsn", false, false); + if (val != NULL && val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_name", false, false); + if (val != NULL && val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_time", false, false); + if (val != NULL && val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_xid", false, false); + if (val != NULL && val[0] != '\0') + ntargets++; + + if (ntargets > 1) + ereport(FATAL, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("multiple recovery targets specified"), + errdetail("At most one of \"recovery_target\", " + "\"recovery_target_lsn\", " + "\"recovery_target_name\", " + "\"recovery_target_time\", " + "\"recovery_target_xid\" can be set."))); +} + /* * read_backup_label: check to see if a backup_label file is present * @@ -4764,32 +4823,17 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) } /* - * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable + * Recovery target settings: At most one of the several recovery_target* + * settings may be set to a non-empty value. The global variable * recoveryTarget tracks which kind of recovery target was chosen. Other * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. - * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * An empty string for any of these GUCs is treated as "not set", equivalent + * to the GUC's default; setting a GUC to an empty string is a no-op and does + * not clear other already-set targets. Conflicts between multiple non-empty + * settings are detected in CheckRecoveryTargetConflicts(), called from + * validateRecoveryParameters() at every startup. */ -pg_noreturn static void -error_multiple_recovery_targets(void) -{ - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); -} - /* * GUC check_hook for recovery_target */ @@ -4810,14 +4854,8 @@ check_recovery_target(char **newval, void **extra, GucSource source) void assign_recovery_target(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_IMMEDIATE; - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -4851,17 +4889,11 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_LSN; recoveryTargetLSN = *((XLogRecPtr *) extra); } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -4886,17 +4918,11 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) void assign_recovery_target_name(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_NAME; recoveryTargetName = newval; } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -4966,14 +4992,8 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) void assign_recovery_target_time(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_TIME; - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -5094,15 +5114,9 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_XID; recoveryTargetXid = *((TransactionId *) extra); } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..ef22a80252b 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -125,11 +125,24 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params, test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, "5000", $lsn5); +# Regression: empty-string for one recovery_target_* GUC must not clobber +# another non-empty target. Setting recovery_target_xid + recovery_target_time +# = '' must recover to the xid, not run as no-target recovery. +@recovery_params = ( + "recovery_target_xid = '$recovery_txid'", + "recovery_target_time = ''"); +test_recovery_standby('xid with empty time GUC', + 'standby_xid_empty_time', $node_primary, \@recovery_params, + "2000", $lsn2); + # Multiple targets # -# Multiple conflicting settings are not allowed, but setting the same -# parameter multiple times or unsetting a parameter and setting a -# different one is allowed. +# Multiple conflicting non-empty settings are not allowed. Setting the same +# parameter multiple times is allowed (the last value wins, per ProcessConfigFile +# duplicate handling). An empty string for a recovery_target_* GUC is treated +# as the GUC's default and is a no-op; it does not clear any other already-set +# target. Conflict detection runs in CheckRecoveryTargetConflicts() at every +# server start, regardless of whether recovery is requested. @recovery_params = ( "recovery_target_name = '$recovery_name'", @@ -190,6 +203,35 @@ like( qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicting recovery targets are rejected at every startup, regardless of +# whether recovery.signal is present. Use a throwaway cluster initialized +# from the existing backup so we can leave conflicting GUCs in its +# postgresql.conf without polluting the primary used by later tests. +# init_from_backup is called without has_restoring, so no recovery.signal +# is created and the cluster would normally start as a plain primary; the +# conflicting recovery_target_* GUCs must be rejected anyway. +my $node_no_signal = PostgreSQL::Test::Cluster->new('multi_target_no_signal'); +$node_no_signal->init_from_backup($node_primary, 'my_backup'); +$node_no_signal->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); + +my $res_no_signal = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_no_signal->data_dir, + '--log' => $node_no_signal->logfile, + 'start', + ]); +ok(!$res_no_signal, + 'server fails to start with conflicting recovery targets and no recovery.signal'); + +my $logfile_no_signal = slurp_file($node_no_signal->logfile()); +like( + $logfile_no_signal, + qr/multiple recovery targets specified/, + 'expected error message logged without recovery.signal'); + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.52.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-05-01 05:53  Fujii Masao <masao.fujii@gmail.com>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Fujii Masao @ 2026-05-01 05:53 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

On Wed, Apr 29, 2026 at 6:30 PM JoongHyuk Shin <sjh910805@gmail.com> wrote:
>
> Thanks for the reviews.
>
> v2 attached.

Thanks for updating the patch!

When I started postgres with the following command, recovery_target_xid was
treated as unset in the master, but with the patch the recovery_target_xid=700
setting was used instead. This behavior seems unexpected to me. Thoughts?

    postgres -D data -c "recovery_target_xid=700" -c "recovery_target_xid="

Regards,


-- 
Fujii Masao





^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-05-11 03:17  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Fujii Masao <masao.fujii@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-05-11 03:17 UTC (permalink / raw)
  To: Fujii Masao <masao.fujii@gmail.com>; +Cc: Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

Thanks for the reviews.

v3 attached.

Expected behavior matrix:

  Same GUC, non-empty then empty  -> the empty wins; target unset
  Same GUC, empty then non-empty  -> the non-empty wins; target set
  Cross-GUC, both non-empty       -> error (multiple recovery targets)
  Cross-GUC, one empty            -> the non-empty GUC's target stands
  All empty                       -> no target, end-of-WAL recovery

* Restored same-GUC last-wins (row 1).  v2 dropped each hook's
  `else recoveryTarget = UNSET`; v3 narrows it to
  `else if (recoveryTarget == MY_TYPE)`.

* Cross-GUC empty stays a no-op (row 4), as v2 introduced.
  Strict reject via a source-aware variant is feasible
  if reviewers prefer.

* 003_recovery_targets.pl gains seven CLI-path cases;
  postgresql.conf dedup cannot exercise the same-GUC clear path.

--
JH Shin

On Fri, May 1, 2026 at 2:53 PM Fujii Masao <masao.fujii@gmail.com> wrote:

> On Wed, Apr 29, 2026 at 6:30 PM JoongHyuk Shin <sjh910805@gmail.com>
> wrote:
> >
> > Thanks for the reviews.
> >
> > v2 attached.
>
> Thanks for updating the patch!
>
> When I started postgres with the following command, recovery_target_xid was
> treated as unset in the master, but with the patch the
> recovery_target_xid=700
> setting was used instead. This behavior seems unexpected to me. Thoughts?
>
>     postgres -D data -c "recovery_target_xid=700" -c "recovery_target_xid="
>
> Regards,
>
>
> --
> Fujii Masao
>

Attachments:

v3-0001-Don-t-call-ereport-ERROR-from-recovery-target-GUC.patch (application/octet-stream, 18.0K) download From ef75dc7e4e070e7b5a7aa9c8693ae4b045f61242 Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Sun, 10 May 2026 20:22:15 +0900 Subject: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks The five recovery target GUC assign hooks (assign_recovery_target, assign_recovery_target_lsn, assign_recovery_target_name, assign_recovery_target_time, assign_recovery_target_xid) all called error_multiple_recovery_targets(), which invoked ereport(ERROR). The GUC README explicitly states that assign hooks must never fail; raising an error from an assign hook leaves guc.c's internal state inconsistent before the abort. A comment in the code itself acknowledged this as "broken by design." Fix this by removing the conflict check from all five assign hooks and detecting multiple recovery targets in a new CheckRecoveryTargetConflicts() function, called from validateRecoveryParameters() before its early return for !ArchiveRecoveryRequested. The check therefore runs at every startup regardless of recovery mode, preserving the existing behavior of detecting misconfiguration at server start time (rather than only when recovery.signal is added). In each assign hook's empty-string branch, replace the unconditional "recoveryTarget = RECOVERY_TARGET_UNSET" assignment with a narrower "if (recoveryTarget == MY_TYPE)" form. Empty strings for an unrelated recovery_target_* GUC then leave another GUC's already-set target intact (this fixes the cross-GUC clobber raised in v1 review), while still preserving the documented "last value wins" reassignment semantics for the same GUC: assigning a recovery target and then reassigning the same parameter to an empty string unsets the target, matching how the assign hooks behaved before this patch and what 003_recovery_targets.pl already expected. --- src/backend/access/transam/xlogrecovery.c | 127 +++++++++------ src/test/recovery/t/003_recovery_targets.pl | 166 +++++++++++++++++++- 2 files changed, 244 insertions(+), 49 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index c236e2b7969..721d9f0e367 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -341,6 +341,7 @@ static void ApplyWalRecord(XLogReaderState *xlogreader, XLogRecord *record, Time static void EnableStandbyMode(void); static void readRecoverySignalFile(void); static void validateRecoveryParameters(void); +static void CheckRecoveryTargetConflicts(void); static bool read_backup_label(XLogRecPtr *checkPointLoc, TimeLineID *backupLabelTLI, bool *backupEndRequired, bool *backupFromStandby); @@ -1067,6 +1068,8 @@ readRecoverySignalFile(void) static void validateRecoveryParameters(void) { + CheckRecoveryTargetConflicts(); + if (!ArchiveRecoveryRequested) return; @@ -1144,6 +1147,63 @@ validateRecoveryParameters(void) } } +/* + * CheckRecoveryTargetConflicts + * + * Validate that at most one of the recovery_target_* GUCs is set to a + * non-empty value. This is called from validateRecoveryParameters() at every + * server startup, regardless of whether archive recovery is requested, so + * misconfiguration is detected at server start time rather than later when + * recovery.signal is added. + * + * The check is a separate function rather than inlined into + * validateRecoveryParameters() because it intentionally runs even when no + * recovery is requested, while the rest of validateRecoveryParameters() is + * recovery-mode-only. Keeping it as a named function makes that separation + * explicit. + * + * The check used to live in the assign hooks of the recovery_target_* GUCs + * (calling ereport(ERROR) on conflict), which violated guc.c's contract that + * assign hooks must never fail. Moving the check here keeps the assign hooks + * contract-compliant. + * + * If a future patch adds a sixth recovery_target_* GUC, both this list and + * the errdetail below must be updated. + */ +static void +CheckRecoveryTargetConflicts(void) +{ + int ntargets = 0; + const char *val; + + /* missing_ok=false guarantees val is non-NULL. */ + val = GetConfigOption("recovery_target", false, false); + if (val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_lsn", false, false); + if (val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_name", false, false); + if (val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_time", false, false); + if (val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_xid", false, false); + if (val[0] != '\0') + ntargets++; + + if (ntargets > 1) + ereport(FATAL, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("multiple recovery targets specified"), + errdetail("At most one of \"recovery_target\", " + "\"recovery_target_lsn\", " + "\"recovery_target_name\", " + "\"recovery_target_time\", " + "\"recovery_target_xid\" can be set."))); +} + /* * read_backup_label: check to see if a backup_label file is present * @@ -4764,32 +4824,27 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) } /* - * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable + * Recovery target settings: At most one of the several recovery_target* + * settings may be set to a non-empty value. The global variable * recoveryTarget tracks which kind of recovery target was chosen. Other * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. + * An empty string for any of these GUCs is treated as "not set", equivalent + * to the GUC's default; an empty value cannot clobber another GUC's + * already-set target. Conflicts between multiple non-empty settings are + * detected in CheckRecoveryTargetConflicts(), called from + * validateRecoveryParameters() at every startup. * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * Each assign hook clears recoveryTarget only when its own GUC is reassigned + * to an empty string after the same GUC was previously assigned a non-empty + * value, e.g. + * postgres -c recovery_target_xid=700 -c recovery_target_xid='' + * (postgresql.conf collapses duplicate keys so only the last value reaches + * the assign hook; this same-parameter set-then-clear case only arises from + * -c). The clear is restricted to the hook's own target type so that an + * empty value for one recovery_target_* GUC cannot clobber another GUC's + * already-set target. */ -pg_noreturn static void -error_multiple_recovery_targets(void) -{ - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); -} - /* * GUC check_hook for recovery_target */ @@ -4810,13 +4865,9 @@ check_recovery_target(char **newval, void **extra, GucSource source) void assign_recovery_target(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_IMMEDIATE; - else + else if (recoveryTarget == RECOVERY_TARGET_IMMEDIATE) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4851,16 +4902,12 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_LSN; recoveryTargetLSN = *((XLogRecPtr *) extra); } - else + else if (recoveryTarget == RECOVERY_TARGET_LSN) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4886,16 +4933,12 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) void assign_recovery_target_name(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_NAME; recoveryTargetName = newval; } - else + else if (recoveryTarget == RECOVERY_TARGET_NAME) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4966,13 +5009,9 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) void assign_recovery_target_time(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_TIME; - else + else if (recoveryTarget == RECOVERY_TARGET_TIME) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -5094,15 +5133,11 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_XID; recoveryTargetXid = *((TransactionId *) extra); } - else + else if (recoveryTarget == RECOVERY_TARGET_XID) recoveryTarget = RECOVERY_TARGET_UNSET; } diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..bf871fb95f9 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -51,6 +51,49 @@ sub test_recovery_standby return; } +# Start a standby with the given pg_ctl --options string and verify that +# the standby reaches the given LSN and row count. Used to exercise +# scenarios that require the postmaster command line to receive multiple +# "-c name=value" instances of the same GUC, which postgresql.conf cannot +# express because ProcessConfigFile collapses duplicate keys. +sub test_recovery_standby_with_options +{ + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my $test_name = shift; + my $node_name = shift; + my $node_primary = shift; + my $options = shift; + my $num_rows = shift; + my $until_lsn = shift; + + my $node_standby = PostgreSQL::Test::Cluster->new($node_name); + $node_standby->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + + my $res = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_standby->data_dir, + '--log' => $node_standby->logfile, + '--options' => $options, + 'start', + ]); + ok($res, "server starts for $test_name"); + + $node_standby->poll_query_until('postgres', + "SELECT '$until_lsn'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; + + my $count = $node_standby->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); + is($count, qq($num_rows), "check standby content for $test_name"); + + $node_standby->teardown_node; + + return; +} + # Initialize primary node my $node_primary = PostgreSQL::Test::Cluster->new('primary'); $node_primary->init(has_archiving => 1, allows_streaming => 1); @@ -108,6 +151,13 @@ $node_primary->safe_psql('postgres', # Force archiving of WAL file $node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); +# LSN after the final 6000-row insert and WAL switch. Used by the +# set-then-cleared scenarios below where recovery has no target and must +# replay all archived WAL; polling on $lsn5 would race against the 5001-6000 +# rows. +my $lsn6 = + $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); + # Test recovery targets my @recovery_params = ("recovery_target = 'immediate'"); test_recovery_standby('immediate target', @@ -125,11 +175,24 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params, test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, "5000", $lsn5); +# Regression: empty-string for one recovery_target_* GUC must not clobber +# another non-empty target. Setting recovery_target_xid + recovery_target_time +# = '' must recover to the xid, not run as no-target recovery. +@recovery_params = ( + "recovery_target_xid = '$recovery_txid'", + "recovery_target_time = ''"); +test_recovery_standby('xid with empty time GUC', + 'standby_xid_empty_time', $node_primary, \@recovery_params, + "2000", $lsn2); + # Multiple targets # -# Multiple conflicting settings are not allowed, but setting the same -# parameter multiple times or unsetting a parameter and setting a -# different one is allowed. +# Multiple conflicting non-empty settings are not allowed. Setting the same +# parameter multiple times is allowed (the last value wins, per ProcessConfigFile +# duplicate handling). An empty string for a recovery_target_* GUC is treated +# as the GUC's default and is a no-op; it does not clear any other already-set +# target. Conflict detection runs in CheckRecoveryTargetConflicts() at every +# server start, regardless of whether recovery is requested. @recovery_params = ( "recovery_target_name = '$recovery_name'", @@ -190,6 +253,103 @@ like( qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicting recovery targets are rejected at every startup, regardless of +# whether recovery.signal is present. Use a throwaway cluster initialized +# from the existing backup so we can leave conflicting GUCs in its +# postgresql.conf without polluting the primary used by later tests. +# init_from_backup is called without has_restoring, so no recovery.signal +# is created and the cluster would normally start as a plain primary; the +# conflicting recovery_target_* GUCs must be rejected anyway. +my $node_no_signal = PostgreSQL::Test::Cluster->new('multi_target_no_signal'); +$node_no_signal->init_from_backup($node_primary, 'my_backup'); +$node_no_signal->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); + +my $res_no_signal = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_no_signal->data_dir, + '--log' => $node_no_signal->logfile, + 'start', + ]); +ok(!$res_no_signal, + 'server fails to start with conflicting recovery targets and no recovery.signal'); + +my $logfile_no_signal = slurp_file($node_no_signal->logfile()); +like( + $logfile_no_signal, + qr/multiple recovery targets specified/, + 'expected error message logged without recovery.signal'); + +# Same-GUC last-wins (one source of truth for the GUC's value): assigning a +# recovery_target_* GUC and then assigning the same GUC to an empty string +# leaves no target set and recovery proceeds to the end of WAL. This is the +# "set then unset" form of GUC reassignment; the assign hook clears its own +# type when the new value is empty. postgresql.conf cannot express duplicate +# keys (ProcessConfigFile collapses them), so the postmaster command line is +# used via "pg_ctl --options". Each of the five recovery_target_* assign +# hooks is exercised once. + +test_recovery_standby_with_options( + 'recovery_target_xid set then cleared', + 'standby_xid_set_clear', $node_primary, + "-c recovery_target_xid='$recovery_txid' -c recovery_target_xid=''", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_time set then cleared', + 'standby_time_set_clear', $node_primary, + "-c recovery_target_time='$recovery_time' -c recovery_target_time=''", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_name set then cleared', + 'standby_name_set_clear', $node_primary, + "-c recovery_target_name='$recovery_name' -c recovery_target_name=''", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_lsn set then cleared', + 'standby_lsn_set_clear', $node_primary, + "-c recovery_target_lsn='$recovery_lsn' -c recovery_target_lsn=''", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target set then cleared', + 'standby_immediate_set_clear', $node_primary, + "-c recovery_target='immediate' -c recovery_target=''", + "6000", $lsn6); + +# Same-GUC empty-then-set sanity: assigning an empty string and then a +# non-empty value to the same GUC must end with the target set. The empty +# value seen first must not poison a later non-empty assignment. +my $node_xid_clear_set = + PostgreSQL::Test::Cluster->new('standby_xid_clear_set'); +$node_xid_clear_set->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + +my $res_xid_clear_set = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_xid_clear_set->data_dir, + '--log' => $node_xid_clear_set->logfile, + '--options' => + "-c recovery_target_xid='' -c recovery_target_xid='$recovery_txid'", + 'start', + ]); +ok($res_xid_clear_set, + 'server starts with recovery_target_xid cleared then set'); + +$node_xid_clear_set->poll_query_until('postgres', + "SELECT '$lsn2'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; +my $count_xid_clear_set = $node_xid_clear_set->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); +is($count_xid_clear_set, "2000", + 'recovery_target_xid honored when cleared then set'); +$node_xid_clear_set->teardown_node; + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.52.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-05-11 06:01  JoongHyuk Shin <sjh910805@gmail.com>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-05-11 06:01 UTC (permalink / raw)
  To: Fujii Masao <masao.fujii@gmail.com>; +Cc: Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

CFBot flagged a Windows MSVC failure on v3.  Root cause: the new TAP
cases pass GUC values to pg_ctl via "--options" using single-quoted
shell tokens, which Windows cmd.exe does not strip the way POSIX
shells do, so postgres receives the quotes verbatim and rejects the
values.

v4 drops the single quotes and switches recovery_target_time from the
space-containing now() format to ISO 8601 with the T separator, so
each value is a single token without quoting.  All other platforms
already passed; this only affected the new TAP cases on Windows MSVC.

No functional change.

--
JH Shin

On Mon, May 11, 2026 at 12:17 PM JoongHyuk Shin <sjh910805@gmail.com> wrote:

> Thanks for the reviews.
>
> v3 attached.
>
> Expected behavior matrix:
>
>   Same GUC, non-empty then empty  -> the empty wins; target unset
>   Same GUC, empty then non-empty  -> the non-empty wins; target set
>   Cross-GUC, both non-empty       -> error (multiple recovery targets)
>   Cross-GUC, one empty            -> the non-empty GUC's target stands
>   All empty                       -> no target, end-of-WAL recovery
>
> * Restored same-GUC last-wins (row 1).  v2 dropped each hook's
>   `else recoveryTarget = UNSET`; v3 narrows it to
>   `else if (recoveryTarget == MY_TYPE)`.
>
> * Cross-GUC empty stays a no-op (row 4), as v2 introduced.
>   Strict reject via a source-aware variant is feasible
>   if reviewers prefer.
>
> * 003_recovery_targets.pl gains seven CLI-path cases;
>   postgresql.conf dedup cannot exercise the same-GUC clear path.
>
> --
> JH Shin
>
> On Fri, May 1, 2026 at 2:53 PM Fujii Masao <masao.fujii@gmail.com> wrote:
>
>> On Wed, Apr 29, 2026 at 6:30 PM JoongHyuk Shin <sjh910805@gmail.com>
>> wrote:
>> >
>> > Thanks for the reviews.
>> >
>> > v2 attached.
>>
>> Thanks for updating the patch!
>>
>> When I started postgres with the following command, recovery_target_xid
>> was
>> treated as unset in the master, but with the patch the
>> recovery_target_xid=700
>> setting was used instead. This behavior seems unexpected to me. Thoughts?
>>
>>     postgres -D data -c "recovery_target_xid=700" -c
>> "recovery_target_xid="
>>
>> Regards,
>>
>>
>> --
>> Fujii Masao
>>
>

Attachments:

v4-0001-Don-t-call-ereport-ERROR-from-recovery-target-GUC.patch (application/octet-stream, 18.4K) download From 68212bb8107a119ffe7f87113121230c87415b49 Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Sun, 10 May 2026 20:22:15 +0900 Subject: [PATCH v4] Don't call ereport(ERROR) from recovery target GUC assign hooks The five recovery target GUC assign hooks (assign_recovery_target, assign_recovery_target_lsn, assign_recovery_target_name, assign_recovery_target_time, assign_recovery_target_xid) all called error_multiple_recovery_targets(), which invoked ereport(ERROR). The GUC README explicitly states that assign hooks must never fail; raising an error from an assign hook leaves guc.c's internal state inconsistent before the abort. A comment in the code itself acknowledged this as "broken by design." Fix this by removing the conflict check from all five assign hooks and detecting multiple recovery targets in a new CheckRecoveryTargetConflicts() function, called from validateRecoveryParameters() before its early return for !ArchiveRecoveryRequested. The check therefore runs at every startup regardless of recovery mode, preserving the existing behavior of detecting misconfiguration at server start time (rather than only when recovery.signal is added). In each assign hook's empty-string branch, replace the unconditional "recoveryTarget = RECOVERY_TARGET_UNSET" assignment with a narrower "if (recoveryTarget == MY_TYPE)" form. Empty strings for an unrelated recovery_target_* GUC then leave another GUC's already-set target intact (this fixes the cross-GUC clobber raised in v1 review), while still preserving the documented "last value wins" reassignment semantics for the same GUC: assigning a recovery target and then reassigning the same parameter to an empty string unsets the target, matching how the assign hooks behaved before this patch and what 003_recovery_targets.pl already expected. --- src/backend/access/transam/xlogrecovery.c | 127 ++++++++------ src/test/recovery/t/003_recovery_targets.pl | 174 +++++++++++++++++++- 2 files changed, 252 insertions(+), 49 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index c236e2b7969..953cdb48a3d 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -341,6 +341,7 @@ static void ApplyWalRecord(XLogReaderState *xlogreader, XLogRecord *record, Time static void EnableStandbyMode(void); static void readRecoverySignalFile(void); static void validateRecoveryParameters(void); +static void CheckRecoveryTargetConflicts(void); static bool read_backup_label(XLogRecPtr *checkPointLoc, TimeLineID *backupLabelTLI, bool *backupEndRequired, bool *backupFromStandby); @@ -1067,6 +1068,8 @@ readRecoverySignalFile(void) static void validateRecoveryParameters(void) { + CheckRecoveryTargetConflicts(); + if (!ArchiveRecoveryRequested) return; @@ -1144,6 +1147,63 @@ validateRecoveryParameters(void) } } +/* + * CheckRecoveryTargetConflicts + * + * Validate that at most one of the recovery_target_* GUCs is set to a + * non-empty value. This is called from validateRecoveryParameters() at every + * server startup, regardless of whether archive recovery is requested, so + * misconfiguration is detected at server start time rather than later when + * recovery.signal is added. + * + * The check is a separate function rather than inlined into + * validateRecoveryParameters() because it intentionally runs even when no + * recovery is requested, while the rest of validateRecoveryParameters() is + * recovery-mode-only. Keeping it as a named function makes that separation + * explicit. + * + * The check used to live in the assign hooks of the recovery_target_* GUCs + * (calling ereport(ERROR) on conflict), which violated guc.c's contract that + * assign hooks must never fail. Moving the check here keeps the assign hooks + * contract-compliant. + * + * If a future patch adds a sixth recovery_target_* GUC, both this list and + * the errdetail below must be updated. + */ +static void +CheckRecoveryTargetConflicts(void) +{ + int ntargets = 0; + const char *val; + + /* missing_ok=false guarantees val is non-NULL. */ + val = GetConfigOption("recovery_target", false, false); + if (val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_lsn", false, false); + if (val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_name", false, false); + if (val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_time", false, false); + if (val[0] != '\0') + ntargets++; + val = GetConfigOption("recovery_target_xid", false, false); + if (val[0] != '\0') + ntargets++; + + if (ntargets > 1) + ereport(FATAL, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("multiple recovery targets specified"), + errdetail("At most one of \"recovery_target\", " + "\"recovery_target_lsn\", " + "\"recovery_target_name\", " + "\"recovery_target_time\", " + "\"recovery_target_xid\" can be set."))); +} + /* * read_backup_label: check to see if a backup_label file is present * @@ -4764,32 +4824,27 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) } /* - * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable + * Recovery target settings: At most one of the several recovery_target* + * settings may be set to a non-empty value. The global variable * recoveryTarget tracks which kind of recovery target was chosen. Other * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. + * An empty string for any of these GUCs is treated as "not set", equivalent + * to the GUC's default; an empty value cannot clobber another GUC's + * already-set target. Conflicts between multiple non-empty settings are + * detected in CheckRecoveryTargetConflicts(), called from + * validateRecoveryParameters() at every startup. * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * Each assign hook clears recoveryTarget only when its own GUC is reassigned + * to an empty string after the same GUC was previously assigned a non-empty + * value, e.g. + * postgres -c recovery_target_xid=700 -c recovery_target_xid= + * (postgresql.conf collapses duplicate keys so only the last value reaches + * the assign hook; this same-parameter set-then-clear case only arises from + * -c). The clear is restricted to the hook's own target type so that an + * empty value for one recovery_target_* GUC cannot clobber another GUC's + * already-set target. */ -pg_noreturn static void -error_multiple_recovery_targets(void) -{ - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); -} - /* * GUC check_hook for recovery_target */ @@ -4810,13 +4865,9 @@ check_recovery_target(char **newval, void **extra, GucSource source) void assign_recovery_target(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_IMMEDIATE; - else + else if (recoveryTarget == RECOVERY_TARGET_IMMEDIATE) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4851,16 +4902,12 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_LSN; recoveryTargetLSN = *((XLogRecPtr *) extra); } - else + else if (recoveryTarget == RECOVERY_TARGET_LSN) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4886,16 +4933,12 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) void assign_recovery_target_name(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_NAME; recoveryTargetName = newval; } - else + else if (recoveryTarget == RECOVERY_TARGET_NAME) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4966,13 +5009,9 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) void assign_recovery_target_time(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_TIME; - else + else if (recoveryTarget == RECOVERY_TARGET_TIME) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -5094,15 +5133,11 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_XID; recoveryTargetXid = *((TransactionId *) extra); } - else + else if (recoveryTarget == RECOVERY_TARGET_XID) recoveryTarget = RECOVERY_TARGET_UNSET; } diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..5979663b0ab 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -51,6 +51,49 @@ sub test_recovery_standby return; } +# Start a standby with the given pg_ctl --options string and verify that +# the standby reaches the given LSN and row count. Used to exercise +# scenarios that require the postmaster command line to receive multiple +# "-c name=value" instances of the same GUC, which postgresql.conf cannot +# express because ProcessConfigFile collapses duplicate keys. +sub test_recovery_standby_with_options +{ + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my $test_name = shift; + my $node_name = shift; + my $node_primary = shift; + my $options = shift; + my $num_rows = shift; + my $until_lsn = shift; + + my $node_standby = PostgreSQL::Test::Cluster->new($node_name); + $node_standby->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + + my $res = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_standby->data_dir, + '--log' => $node_standby->logfile, + '--options' => $options, + 'start', + ]); + ok($res, "server starts for $test_name"); + + $node_standby->poll_query_until('postgres', + "SELECT '$until_lsn'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; + + my $count = $node_standby->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); + is($count, qq($num_rows), "check standby content for $test_name"); + + $node_standby->teardown_node; + + return; +} + # Initialize primary node my $node_primary = PostgreSQL::Test::Cluster->new('primary'); $node_primary->init(has_archiving => 1, allows_streaming => 1); @@ -108,6 +151,13 @@ $node_primary->safe_psql('postgres', # Force archiving of WAL file $node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); +# LSN after the final 6000-row insert and WAL switch. Used by the +# set-then-cleared scenarios below where recovery has no target and must +# replay all archived WAL; polling on $lsn5 would race against the 5001-6000 +# rows. +my $lsn6 = + $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); + # Test recovery targets my @recovery_params = ("recovery_target = 'immediate'"); test_recovery_standby('immediate target', @@ -125,11 +175,24 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params, test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, "5000", $lsn5); +# Regression: empty-string for one recovery_target_* GUC must not clobber +# another non-empty target. Setting recovery_target_xid + recovery_target_time +# = '' must recover to the xid, not run as no-target recovery. +@recovery_params = ( + "recovery_target_xid = '$recovery_txid'", + "recovery_target_time = ''"); +test_recovery_standby('xid with empty time GUC', + 'standby_xid_empty_time', $node_primary, \@recovery_params, + "2000", $lsn2); + # Multiple targets # -# Multiple conflicting settings are not allowed, but setting the same -# parameter multiple times or unsetting a parameter and setting a -# different one is allowed. +# Multiple conflicting non-empty settings are not allowed. Setting the same +# parameter multiple times is allowed (the last value wins, per ProcessConfigFile +# duplicate handling). An empty string for a recovery_target_* GUC is treated +# as the GUC's default and is a no-op; it does not clear any other already-set +# target. Conflict detection runs in CheckRecoveryTargetConflicts() at every +# server start, regardless of whether recovery is requested. @recovery_params = ( "recovery_target_name = '$recovery_name'", @@ -190,6 +253,111 @@ like( qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicting recovery targets are rejected at every startup, regardless of +# whether recovery.signal is present. Use a throwaway cluster initialized +# from the existing backup so we can leave conflicting GUCs in its +# postgresql.conf without polluting the primary used by later tests. +# init_from_backup is called without has_restoring, so no recovery.signal +# is created and the cluster would normally start as a plain primary; the +# conflicting recovery_target_* GUCs must be rejected anyway. +my $node_no_signal = PostgreSQL::Test::Cluster->new('multi_target_no_signal'); +$node_no_signal->init_from_backup($node_primary, 'my_backup'); +$node_no_signal->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); + +my $res_no_signal = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_no_signal->data_dir, + '--log' => $node_no_signal->logfile, + 'start', + ]); +ok(!$res_no_signal, + 'server fails to start with conflicting recovery targets and no recovery.signal'); + +my $logfile_no_signal = slurp_file($node_no_signal->logfile()); +like( + $logfile_no_signal, + qr/multiple recovery targets specified/, + 'expected error message logged without recovery.signal'); + +# Same-GUC last-wins (one source of truth for the GUC's value): assigning a +# recovery_target_* GUC and then assigning the same GUC to an empty string +# leaves no target set and recovery proceeds to the end of WAL. This is the +# "set then unset" form of GUC reassignment; the assign hook clears its own +# type when the new value is empty. postgresql.conf cannot express duplicate +# keys (ProcessConfigFile collapses them), so the postmaster command line is +# used via "pg_ctl --options". Each of the five recovery_target_* assign +# hooks is exercised once. +# +# Note: GUC values below are passed unquoted on the command line. Windows +# cmd.exe does not strip single quotes the way POSIX shells do, so quoted +# values would reach the postmaster verbatim and be rejected. recovery_time +# from now() contains a space; we replace it with the ISO 8601 T separator +# so the value is a single shell token without quoting. +my $recovery_time_t = $recovery_time; +$recovery_time_t =~ s/ /T/; + +test_recovery_standby_with_options( + 'recovery_target_xid set then cleared', + 'standby_xid_set_clear', $node_primary, + "-c recovery_target_xid=$recovery_txid -c recovery_target_xid=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_time set then cleared', + 'standby_time_set_clear', $node_primary, + "-c recovery_target_time=$recovery_time_t -c recovery_target_time=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_name set then cleared', + 'standby_name_set_clear', $node_primary, + "-c recovery_target_name=$recovery_name -c recovery_target_name=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_lsn set then cleared', + 'standby_lsn_set_clear', $node_primary, + "-c recovery_target_lsn=$recovery_lsn -c recovery_target_lsn=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target set then cleared', + 'standby_immediate_set_clear', $node_primary, + "-c recovery_target=immediate -c recovery_target=", + "6000", $lsn6); + +# Same-GUC empty-then-set sanity: assigning an empty string and then a +# non-empty value to the same GUC must end with the target set. The empty +# value seen first must not poison a later non-empty assignment. +my $node_xid_clear_set = + PostgreSQL::Test::Cluster->new('standby_xid_clear_set'); +$node_xid_clear_set->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + +my $res_xid_clear_set = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_xid_clear_set->data_dir, + '--log' => $node_xid_clear_set->logfile, + '--options' => + "-c recovery_target_xid= -c recovery_target_xid=$recovery_txid", + 'start', + ]); +ok($res_xid_clear_set, + 'server starts with recovery_target_xid cleared then set'); + +$node_xid_clear_set->poll_query_until('postgres', + "SELECT '$lsn2'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; +my $count_xid_clear_set = $node_xid_clear_set->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); +is($count_xid_clear_set, "2000", + 'recovery_target_xid honored when cleared then set'); +$node_xid_clear_set->teardown_node; + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.52.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-05-31 21:11  Scott Ray <scott@scottray.io>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Scott Ray @ 2026-05-31 21:11 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

Thanks for the patch.  I've attached v1-0001 (atop v4) addressing the
UX and test-coverage items below.  Happy to rework or fold in however
you prefer.

1. There's a configuration trap in master and in this branch that
could be prevented using something very similar to
CheckRecoveryTargetConflicts to check pending GUCs:

    psql -c "ALTER SYSTEM SET recovery_target_xid TO '700'"
    psql -c "ALTER SYSTEM SET recovery_target_time TO '2026-01-01 00:00:00'"
    pg_ctl reload

The log shows:

    LOG:  received SIGHUP, reloading configuration files
    LOG:  parameter "recovery_target_xid" cannot be changed without restarting the server
    LOG:  parameter "recovery_target_time" cannot be changed without restarting the server
    LOG:  configuration file "postgresql.auto.conf" contains errors; unaffected changes were applied

pg_settings shows:

    postgres=# SELECT name, setting, pending_restart FROM pg_settings
                WHERE name LIKE 'recovery_target%' AND pending_restart;
            name         | setting | pending_restart
    ---------------------+---------+-----------------
     recovery_target_time |         | t
     recovery_target_xid  |         | t

The db runs fine until the next restart, maybe hours later:

    FATAL:  multiple recovery targets specified
    DETAIL:  At most one of "recovery_target", "recovery_target_lsn",
    "recovery_target_name", "recovery_target_time",
    "recovery_target_xid" can be set.

Is it worth a follow-up to report the conflict early and loud?

2. There's an opportunity to provide a better UX by reporting which
flags were set and what the values were, so that the user doesn't have
to search config files or other logs to find this info.  For instance,
in the postgresql.auto.conf scenario above, instead of:

    DETAIL:  At most one of "recovery_target", "recovery_target_lsn",
    "recovery_target_name", "recovery_target_time",
    "recovery_target_xid" can be set.

The operator could see:

    DETAIL:  The following recovery target parameters are set:
    "recovery_target_time" = "2026-01-01 00:00:00",
    "recovery_target_xid" = "700".
    HINT:  At most one of "recovery_target", "recovery_target_lsn",
    "recovery_target_name", "recovery_target_time",
    "recovery_target_xid" can be set.

3. 003_recovery_targets.pl:339 currently tests recovery_target_xid's
cleared-then-set behavior.  The patch adds the same coverage for the
other four recovery_target_* GUCs.


--
Scott Ray

Attachments:

v1-0001-Report-set-parameters-on-recovery_target-conflict.patch (application/octet-stream, 6.2K) download From c61284c824429778ad8f123e22862931d41a2a6d Mon Sep 17 00:00:00 2001 From: Scott Ray <scott@scottray.io> Date: Sun, 31 May 2026 13:12:29 -0700 Subject: [PATCH v1] Report set parameters on recovery_target conflict; expand tests v4 of "Don't call ereport(ERROR) from recovery target GUC assign hooks" produces a FATAL with an errdetail that lists all five recovery_target_* GUCs regardless of which the operator actually set, and exercises only recovery_target_xid in the cleared-then-set direction. This patch makes CheckRecoveryTargetConflicts() report the names and values of the GUCs that are actually non-empty in errdetail, moving the "at most one of [list]" enumeration to errhint. The five hand-written GetConfigOption() calls collapse into a loop over a static target_names[] array, so adding a sixth recovery_target_* GUC requires only updating the array; both error messages are derived from it. The TAP test gains four cleared-then-set cases covering time, name, lsn, and the bare recovery_target, mirroring the existing xid case. A new like() assertion verifies that the errdetail names which GUCs are set and their values. Applies atop v4. Discussion: https://postgr.es/m/CACSdjfPUa4UvKjADgOERXoxNYmCg2mqqiqKkiJk6mX6E4qgVFw@mail.gmail.com --- src/backend/access/transam/xlogrecovery.c | 61 ++++++++++++--------- src/test/recovery/t/003_recovery_targets.pl | 40 ++++++++++++++ 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 1253bed1058..e48e21631b2 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -1167,41 +1167,52 @@ validateRecoveryParameters(void) * assign hooks must never fail. Moving the check here keeps the assign hooks * contract-compliant. * - * If a future patch adds a sixth recovery_target_* GUC, both this list and - * the errdetail below must be updated. + * If a future patch adds a sixth recovery_target_* GUC, add its name to + * target_names below; both error messages are derived from that array. */ static void CheckRecoveryTargetConflicts(void) { + static const char *const target_names[] = { + "recovery_target", + "recovery_target_lsn", + "recovery_target_name", + "recovery_target_time", + "recovery_target_xid", + }; + StringInfoData set_targets; + StringInfoData all_targets; int ntargets = 0; - const char *val; - - /* missing_ok=false guarantees val is non-NULL. */ - val = GetConfigOption("recovery_target", false, false); - if (val[0] != '\0') - ntargets++; - val = GetConfigOption("recovery_target_lsn", false, false); - if (val[0] != '\0') - ntargets++; - val = GetConfigOption("recovery_target_name", false, false); - if (val[0] != '\0') - ntargets++; - val = GetConfigOption("recovery_target_time", false, false); - if (val[0] != '\0') - ntargets++; - val = GetConfigOption("recovery_target_xid", false, false); - if (val[0] != '\0') - ntargets++; + + initStringInfo(&set_targets); + initStringInfo(&all_targets); + + for (int i = 0; i < lengthof(target_names); i++) + { + /* missing_ok=false guarantees val is non-NULL. */ + const char *val = GetConfigOption(target_names[i], false, false); + + if (i > 0) + appendStringInfoString(&all_targets, ", "); + appendStringInfo(&all_targets, "\"%s\"", target_names[i]); + + if (val[0] != '\0') + { + if (ntargets > 0) + appendStringInfoString(&set_targets, ", "); + appendStringInfo(&set_targets, "\"%s\" = \"%s\"", + target_names[i], val); + ntargets++; + } + } if (ntargets > 1) ereport(FATAL, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", " - "\"recovery_target_lsn\", " - "\"recovery_target_name\", " - "\"recovery_target_time\", " - "\"recovery_target_xid\" can be set."))); + errdetail("The following recovery target parameters are set: %s.", + set_targets.data), + errhint("At most one of %s can be set.", all_targets.data))); } /* diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 5979663b0ab..3e68c01968b 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -282,6 +282,14 @@ like( qr/multiple recovery targets specified/, 'expected error message logged without recovery.signal'); +# Ordering in the errdetail follows target_names[] in CheckRecoveryTargetConflicts: +# recovery_target, recovery_target_lsn, recovery_target_name, +# recovery_target_time, recovery_target_xid. +like( + $logfile_no_signal, + qr/are set: "recovery_target_name" = "[^"]+", "recovery_target_time" = "[^"]+"/, + 'errdetail names which recovery_target_* GUCs are set and their values'); + # Same-GUC last-wins (one source of truth for the GUC's value): assigning a # recovery_target_* GUC and then assigning the same GUC to an empty string # leaves no target set and recovery proceeds to the end of WAL. This is the @@ -358,6 +366,38 @@ is($count_xid_clear_set, "2000", 'recovery_target_xid honored when cleared then set'); $node_xid_clear_set->teardown_node; +test_recovery_standby_with_options( + 'recovery_target_time cleared then set', + 'standby_time_clear_set', + $node_primary, + "-c recovery_target_time= -c recovery_target_time=$recovery_time_t", + "3000", + $lsn3); + +test_recovery_standby_with_options( + 'recovery_target_name cleared then set', + 'standby_name_clear_set', + $node_primary, + "-c recovery_target_name= -c recovery_target_name=$recovery_name", + "4000", + $lsn4); + +test_recovery_standby_with_options( + 'recovery_target_lsn cleared then set', + 'standby_lsn_clear_set', + $node_primary, + "-c recovery_target_lsn= -c recovery_target_lsn=$recovery_lsn", + "5000", + $lsn5); + +test_recovery_standby_with_options( + 'recovery_target cleared then set', + 'standby_immediate_clear_set', + $node_primary, + "-c recovery_target= -c recovery_target=immediate", + "1000", + $lsn1); + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.50.1 (Apple Git-155)
signature.asc (application/pgp-signature, 341B) download ^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-04 05:41  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Scott Ray <scott@scottray.io>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-06-04 05:41 UTC (permalink / raw)
  To: scott@scottray.io; +Cc: Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

Thanks for the patch.
I went through 'v1-0001-Report-....patch'
and have a few observations to share.

* Function structure: the recovery_target_* set has been
  historically stable, so array + loop abstraction adds limited
  value; function size grows ~34% (32 -> 43 lines) for one line of
  savings on a hypothetical sixth GUC, while the closest precedent
  (archive_command / archive_library in pgarch.c) is a hard-coded literal.

* errhint vs errdetail: errhint("At most one of %s can be set.")
  reads more like a constraint than an action hint.  The closest
  precedent, archive_command / archive_library in pgarch.c
  (ProcessPgArchInterrupts() / LoadArchiveLibrary()), keeps the
  enumeration in errdetail and omits errhint entirely.

* TAP regex: the added like() uses [^"]+ for the values, which
  passes regardless of the actual value.  Using quotemeta on the
  expected values would verify the actual content, and anchoring
  would also avoid accidentally matching the same tokens inside
  errhint.

On the reload trap:
I reproduced this on master and confirmed it's there exactly as you noted.
ALTER SYSTEM doesn't trigger the assign hook;
it just writes to postgresql.auto.conf,
so the trap window is intrinsic to PGC_POSTMASTER + ALTER SYSTEM.
A separate follow-up patch in the reload path feels natural.

-- 
JH Shin

On Mon, Jun 1, 2026 at 6:11 AM Scott Ray <scott@scottray.io> wrote:

> Thanks for the patch.  I've attached v1-0001 (atop v4) addressing the
> UX and test-coverage items below.  Happy to rework or fold in however
> you prefer.
>
> 1. There's a configuration trap in master and in this branch that
> could be prevented using something very similar to
> CheckRecoveryTargetConflicts to check pending GUCs:
>
>     psql -c "ALTER SYSTEM SET recovery_target_xid TO '700'"
>     psql -c "ALTER SYSTEM SET recovery_target_time TO '2026-01-01
> 00:00:00'"
>     pg_ctl reload
>
> The log shows:
>
>     LOG:  received SIGHUP, reloading configuration files
>     LOG:  parameter "recovery_target_xid" cannot be changed without
> restarting the server
>     LOG:  parameter "recovery_target_time" cannot be changed without
> restarting the server
>     LOG:  configuration file "postgresql.auto.conf" contains errors;
> unaffected changes were applied
>
> pg_settings shows:
>
>     postgres=# SELECT name, setting, pending_restart FROM pg_settings
>                 WHERE name LIKE 'recovery_target%' AND pending_restart;
>             name         | setting | pending_restart
>     ---------------------+---------+-----------------
>      recovery_target_time |         | t
>      recovery_target_xid  |         | t
>
> The db runs fine until the next restart, maybe hours later:
>
>     FATAL:  multiple recovery targets specified
>     DETAIL:  At most one of "recovery_target", "recovery_target_lsn",
>     "recovery_target_name", "recovery_target_time",
>     "recovery_target_xid" can be set.
>
> Is it worth a follow-up to report the conflict early and loud?
>
> 2. There's an opportunity to provide a better UX by reporting which
> flags were set and what the values were, so that the user doesn't have
> to search config files or other logs to find this info.  For instance,
> in the postgresql.auto.conf scenario above, instead of:
>
>     DETAIL:  At most one of "recovery_target", "recovery_target_lsn",
>     "recovery_target_name", "recovery_target_time",
>     "recovery_target_xid" can be set.
>
> The operator could see:
>
>     DETAIL:  The following recovery target parameters are set:
>     "recovery_target_time" = "2026-01-01 00:00:00",
>     "recovery_target_xid" = "700".
>     HINT:  At most one of "recovery_target", "recovery_target_lsn",
>     "recovery_target_name", "recovery_target_time",
>     "recovery_target_xid" can be set.
>
> 3. 003_recovery_targets.pl:339 currently tests recovery_target_xid's
> cleared-then-set behavior.  The patch adds the same coverage for the
> other four recovery_target_* GUCs.
>
>
> --
> Scott Ray
>

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-06 20:10  Scott Ray <scott@scottray.io>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Scott Ray @ 2026-06-06 20:10 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

> * Function structure: the recovery_target_* set has been
> historically stable, so array + loop abstraction adds limited
> value; function size grows ~34% (32 -> 43 lines) for one line of
> savings on a hypothetical sixth GUC, while the closest precedent
> (archive_command / archive_library in pgarch.c) is a hard-coded literal.
> 

> * errhint vs errdetail: errhint("At most one of %s can be set.")
> reads more like a constraint than an action hint. The closest
> precedent, archive_command / archive_library in pgarch.c
> (ProcessPgArchInterrupts() / LoadArchiveLibrary()), keeps the
> enumeration in errdetail and omits errhint entirely.
> 

> * TAP regex: the added like() uses [^"]+ for the values, which
> passes regardless of the actual value. Using quotemeta on the
> expected values would verify the actual content, and anchoring
> would also avoid accidentally matching the same tokens inside
> errhint.

Thanks for taking a look. I attached a v2 that applies your suggestions
and uses "set to" instead of "=" to match convention. What do you think?

Sample output:

  FATAL:  multiple recovery targets specified
  DETAIL:  At most one of "recovery_target", "recovery_target_lsn",
  "recovery_target_name", "recovery_target_time",
  "recovery_target_xid" can be set. Currently set:
  "recovery_target_time" set to "2026-01-01 00:00:00",
  "recovery_target_xid" set to "700".

--
Scott Ray

Attachments:

v2-0001-Report-set-parameters-on-recovery_target-conflict.patch (application/octet-stream, 6.0K) download From 9b96e0f327d7ca2f644b259511776ef7286ec72f Mon Sep 17 00:00:00 2001 From: Scott Ray <scott@scottray.io> Date: Sun, 31 May 2026 13:12:29 -0700 Subject: [PATCH v2] Report set parameters on recovery_target conflict; expand tests v4 of "Don't call ereport(ERROR) from recovery target GUC assign hooks" produces a FATAL with an errdetail that lists all five recovery_target_* GUCs regardless of which the operator actually set, and exercises only recovery_target_xid in the cleared-then-set direction. This patch appends the names and values of the GUCs that are actually non-empty as a trailing sentence in the existing errdetail. The "at most one of [list]" enumeration stays in errdetail and no errhint is emitted, matching the archive_command / archive_library precedent in pgarch.c. The TAP test gains four cleared-then-set cases covering time, name, lsn, and the bare recovery_target, mirroring the existing xid case. A new like() assertion verifies that the errdetail names which GUCs are set and their values, using quotemeta on the expected values. Applies atop v4. Discussion: https://postgr.es/m/CACSdjfPUa4UvKjADgOERXoxNYmCg2mqqiqKkiJk6mX6E4qgVFw@mail.gmail.com --- src/backend/access/transam/xlogrecovery.c | 35 ++++++++++++++++-- src/test/recovery/t/003_recovery_targets.pl | 40 +++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 1253bed1058..6af36960ddc 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -1167,31 +1167,58 @@ validateRecoveryParameters(void) * assign hooks must never fail. Moving the check here keeps the assign hooks * contract-compliant. * - * If a future patch adds a sixth recovery_target_* GUC, both this list and - * the errdetail below must be updated. + * If a future patch adds a sixth recovery_target_* GUC, add another + * GetConfigOption block below to include it in the "Currently set:" + * suffix, and extend the fixed enumeration in the errdetail. */ static void CheckRecoveryTargetConflicts(void) { + StringInfoData set_targets; int ntargets = 0; const char *val; + initStringInfo(&set_targets); + /* missing_ok=false guarantees val is non-NULL. */ val = GetConfigOption("recovery_target", false, false); if (val[0] != '\0') + { + appendStringInfo(&set_targets, "\"recovery_target\" set to \"%s\"", val); ntargets++; + } val = GetConfigOption("recovery_target_lsn", false, false); if (val[0] != '\0') + { + if (ntargets > 0) + appendStringInfoString(&set_targets, ", "); + appendStringInfo(&set_targets, "\"recovery_target_lsn\" set to \"%s\"", val); ntargets++; + } val = GetConfigOption("recovery_target_name", false, false); if (val[0] != '\0') + { + if (ntargets > 0) + appendStringInfoString(&set_targets, ", "); + appendStringInfo(&set_targets, "\"recovery_target_name\" set to \"%s\"", val); ntargets++; + } val = GetConfigOption("recovery_target_time", false, false); if (val[0] != '\0') + { + if (ntargets > 0) + appendStringInfoString(&set_targets, ", "); + appendStringInfo(&set_targets, "\"recovery_target_time\" set to \"%s\"", val); ntargets++; + } val = GetConfigOption("recovery_target_xid", false, false); if (val[0] != '\0') + { + if (ntargets > 0) + appendStringInfoString(&set_targets, ", "); + appendStringInfo(&set_targets, "\"recovery_target_xid\" set to \"%s\"", val); ntargets++; + } if (ntargets > 1) ereport(FATAL, @@ -1201,7 +1228,9 @@ CheckRecoveryTargetConflicts(void) "\"recovery_target_lsn\", " "\"recovery_target_name\", " "\"recovery_target_time\", " - "\"recovery_target_xid\" can be set."))); + "\"recovery_target_xid\" can be set. " + "Currently set: %s.", + set_targets.data))); } /* diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 5979663b0ab..f00a82d4e9e 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -282,6 +282,14 @@ like( qr/multiple recovery targets specified/, 'expected error message logged without recovery.signal'); +# Ordering in the errdetail follows the GetConfigOption sequence in +# CheckRecoveryTargetConflicts: recovery_target, recovery_target_lsn, +# recovery_target_name, recovery_target_time, recovery_target_xid. +like( + $logfile_no_signal, + qr/Currently set: "recovery_target_name" set to "\Q$recovery_name\E", "recovery_target_time" set to "\Q$recovery_time\E"/, + 'errdetail names which recovery_target_* GUCs are set and their values'); + # Same-GUC last-wins (one source of truth for the GUC's value): assigning a # recovery_target_* GUC and then assigning the same GUC to an empty string # leaves no target set and recovery proceeds to the end of WAL. This is the @@ -358,6 +366,38 @@ is($count_xid_clear_set, "2000", 'recovery_target_xid honored when cleared then set'); $node_xid_clear_set->teardown_node; +test_recovery_standby_with_options( + 'recovery_target_time cleared then set', + 'standby_time_clear_set', + $node_primary, + "-c recovery_target_time= -c recovery_target_time=$recovery_time_t", + "3000", + $lsn3); + +test_recovery_standby_with_options( + 'recovery_target_name cleared then set', + 'standby_name_clear_set', + $node_primary, + "-c recovery_target_name= -c recovery_target_name=$recovery_name", + "4000", + $lsn4); + +test_recovery_standby_with_options( + 'recovery_target_lsn cleared then set', + 'standby_lsn_clear_set', + $node_primary, + "-c recovery_target_lsn= -c recovery_target_lsn=$recovery_lsn", + "5000", + $lsn5); + +test_recovery_standby_with_options( + 'recovery_target cleared then set', + 'standby_immediate_clear_set', + $node_primary, + "-c recovery_target= -c recovery_target=immediate", + "1000", + $lsn1); + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.50.1 (Apple Git-155)
signature.asc (application/pgp-signature, 341B) download ^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-07 10:30  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Scott Ray <scott@scottray.io>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-06-07 10:30 UTC (permalink / raw)
  To: Scott Ray <scott@scottray.io>; +Cc: Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

On "=" vs "set to": I'd stay with "=".
The list is assembled in appendStringInfo and ends up in the dynamic %s,
so prose like "set to" there never goes through gettext
and would print in English even under a translated lc_messages.
"=" is punctuation, so it sidesteps that.
PG already uses this form in the RI detail,
e.g. Key (%s)=(%s) in ri_triggers.c.

That said, I'm not sure the currently-set list belongs in the errdetail at
all,
since an operator can read the values back from the configuration.
I'd be interested in the committer's view on whether it is worth adding.

-- 
JH Shin

On Sun, Jun 7, 2026 at 5:10 AM Scott Ray <scott@scottray.io> wrote:

> > * Function structure: the recovery_target_* set has been
> > historically stable, so array + loop abstraction adds limited
> > value; function size grows ~34% (32 -> 43 lines) for one line of
> > savings on a hypothetical sixth GUC, while the closest precedent
> > (archive_command / archive_library in pgarch.c) is a hard-coded literal.
> >
>
> > * errhint vs errdetail: errhint("At most one of %s can be set.")
> > reads more like a constraint than an action hint. The closest
> > precedent, archive_command / archive_library in pgarch.c
> > (ProcessPgArchInterrupts() / LoadArchiveLibrary()), keeps the
> > enumeration in errdetail and omits errhint entirely.
> >
>
> > * TAP regex: the added like() uses [^"]+ for the values, which
> > passes regardless of the actual value. Using quotemeta on the
> > expected values would verify the actual content, and anchoring
> > would also avoid accidentally matching the same tokens inside
> > errhint.
>
> Thanks for taking a look. I attached a v2 that applies your suggestions
> and uses "set to" instead of "=" to match convention. What do you think?
>
> Sample output:
>
>   FATAL:  multiple recovery targets specified
>   DETAIL:  At most one of "recovery_target", "recovery_target_lsn",
>   "recovery_target_name", "recovery_target_time",
>   "recovery_target_xid" can be set. Currently set:
>   "recovery_target_time" set to "2026-01-01 00:00:00",
>   "recovery_target_xid" set to "700".
>
> --
> Scott Ray
>

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-07 15:44  Álvaro Herrera <alvherre@kurilemu.de>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Álvaro Herrera @ 2026-06-07 15:44 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Scott Ray <scott@scottray.io>; Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

On 2026-Jun-07, JoongHyuk Shin wrote:

> On "=" vs "set to": I'd stay with "=".
> The list is assembled in appendStringInfo and ends up in the dynamic %s,
> so prose like "set to" there never goes through gettext
> and would print in English even under a translated lc_messages.
> "=" is punctuation, so it sidesteps that.

Agreed on using =, although ...

> That said, I'm not sure the currently-set list belongs in the errdetail at
> all,
> since an operator can read the values back from the configuration.
> I'd be interested in the committer's view on whether it is worth adding.

It may be enough to list which ones are set, without listing their values.
Those can be obtained easily from pg_settings, which can be mentioned in
errhint -- useful also to figure out exactly _where_ they are set (e.g.,
in an include file, postgresql.auto.conf, and so on.)

-- 
Álvaro Herrera        Breisgau, Deutschland  —  https://www.EnterpriseDB.com/





^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-21 09:27  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Álvaro Herrera <alvherre@kurilemu.de>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-06-21 09:27 UTC (permalink / raw)
  To: Álvaro Herrera <alvherre@kurilemu.de>; +Cc: Scott Ray <scott@scottray.io>; Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

Thanks for the reviews.

v5 attached.

The errdetail now lists which recovery_target_* parameters are actually set,
instead of the full candidate list.
This follows Scott's idea to surface the set targets;
following Álvaro, the values are dropped
and a new errhint points to pg_settings for them and their sources.

I kept the "which ones are set" list in errdetail rather than errhint,
it states the current configuration, which reads as detail,
while the errhint carries the actionable pg_settings pointer.

-- 
JH Shin

On Mon, Jun 8, 2026 at 12:44 AM Álvaro Herrera <alvherre@kurilemu.de> wrote:

> On 2026-Jun-07, JoongHyuk Shin wrote:
>
> > On "=" vs "set to": I'd stay with "=".
> > The list is assembled in appendStringInfo and ends up in the dynamic %s,
> > so prose like "set to" there never goes through gettext
> > and would print in English even under a translated lc_messages.
> > "=" is punctuation, so it sidesteps that.
>
> Agreed on using =, although ...
>
> > That said, I'm not sure the currently-set list belongs in the errdetail
> at
> > all,
> > since an operator can read the values back from the configuration.
> > I'd be interested in the committer's view on whether it is worth adding.
>
> It may be enough to list which ones are set, without listing their values.
> Those can be obtained easily from pg_settings, which can be mentioned in
> errhint -- useful also to figure out exactly _where_ they are set (e.g.,
> in an include file, postgresql.auto.conf, and so on.)
>
> --
> Álvaro Herrera        Breisgau, Deutschland  —
> https://www.EnterpriseDB.com/
>

Attachments:

v5-0001-Don-t-call-ereport-ERROR-from-recovery-target-GUC-as.patch (application/octet-stream, 22.5K) download From 3638a7ba5037c46e0106989a6679906c03a2fc6b Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Sun, 21 Jun 2026 17:45:25 +0900 Subject: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks The five recovery target GUC assign hooks (assign_recovery_target, assign_recovery_target_lsn, assign_recovery_target_name, assign_recovery_target_time, assign_recovery_target_xid) all called error_multiple_recovery_targets(), which invoked ereport(ERROR). The GUC README explicitly states that assign hooks must never fail; raising an error from an assign hook leaves guc.c's internal state inconsistent before the abort. A comment in the code itself acknowledged this as "broken by design." Fix this by removing the conflict check from all five assign hooks and detecting multiple recovery targets in a new CheckRecoveryTargetConflicts() function, called from validateRecoveryParameters() before its early return for !ArchiveRecoveryRequested. The check therefore runs at every startup regardless of recovery mode, preserving the existing behavior of detecting misconfiguration at server start time rather than only when recovery.signal is added. In each assign hook's empty-string branch, replace the unconditional "recoveryTarget = RECOVERY_TARGET_UNSET" assignment with a narrower "if (recoveryTarget == MY_TYPE)" form. Empty strings for an unrelated recovery_target_* GUC then leave another GUC's already-set target intact, while still preserving the documented "last value wins" reassignment semantics for the same GUC. When a conflict is detected, the errdetail now lists which recovery_target_* parameters are actually set, rather than the full list of candidate names, and an errhint points to pg_settings for their values and where each is configured. --- src/backend/access/transam/xlogrecovery.c | 159 +++++++++---- src/test/recovery/t/003_recovery_targets.pl | 251 +++++++++++++++++++- 2 files changed, 361 insertions(+), 49 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 4d61795b483..dc8b46a6c81 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -61,6 +61,7 @@ #include "storage/subsystems.h" #include "utils/datetime.h" #include "utils/fmgrprotos.h" +#include "utils/guc.h" #include "utils/guc_hooks.h" #include "utils/pgstat_internal.h" #include "utils/pg_lsn.h" @@ -341,6 +342,7 @@ static void ApplyWalRecord(XLogReaderState *xlogreader, XLogRecord *record, Time static void EnableStandbyMode(void); static void readRecoverySignalFile(void); static void validateRecoveryParameters(void); +static void CheckRecoveryTargetConflicts(void); static bool read_backup_label(XLogRecPtr *checkPointLoc, TimeLineID *backupLabelTLI, bool *backupEndRequired, bool *backupFromStandby); @@ -1067,6 +1069,8 @@ readRecoverySignalFile(void) static void validateRecoveryParameters(void) { + CheckRecoveryTargetConflicts(); + if (!ArchiveRecoveryRequested) return; @@ -1144,6 +1148,94 @@ validateRecoveryParameters(void) } } +/* + * CheckRecoveryTargetConflicts + * + * Validate that at most one of the recovery_target_* GUCs is set to a + * non-empty value. This is called from validateRecoveryParameters() at every + * server startup, regardless of whether archive recovery is requested, so + * misconfiguration is detected at server start time rather than later when + * recovery.signal is added. + * + * The check is a separate function rather than inlined into + * validateRecoveryParameters() because it intentionally runs even when no + * recovery is requested, while the rest of validateRecoveryParameters() is + * recovery-mode-only. Keeping it as a named function makes that separation + * explicit. + * + * The check used to live in the assign hooks of the recovery_target_* GUCs + * (calling ereport(ERROR) on conflict), which violated guc.c's contract that + * assign hooks must never fail. Moving the check here keeps the assign hooks + * contract-compliant. + * + * If a future patch adds a sixth recovery_target_* GUC, it must be added to the + * list of GetConfigOption() checks below; the errdetail builds its list of set + * parameters dynamically, so it needs no change. + */ +static void +CheckRecoveryTargetConflicts(void) +{ + int ntargets = 0; + const char *val; + StringInfoData buf; + + initStringInfo(&buf); + + /* + * These GUCs are all PGC_STRING, so GetConfigOption() returns "" (not + * NULL) when a parameter is unset. Collect the name of each one that is + * set into buf, separated by ", ", for use in the errdetail below. + */ + val = GetConfigOption("recovery_target", false, false); + if (val[0] != '\0') + { + ntargets++; + if (buf.len > 0) + appendStringInfoString(&buf, ", "); + appendStringInfoString(&buf, "\"recovery_target\""); + } + val = GetConfigOption("recovery_target_lsn", false, false); + if (val[0] != '\0') + { + ntargets++; + if (buf.len > 0) + appendStringInfoString(&buf, ", "); + appendStringInfoString(&buf, "\"recovery_target_lsn\""); + } + val = GetConfigOption("recovery_target_name", false, false); + if (val[0] != '\0') + { + ntargets++; + if (buf.len > 0) + appendStringInfoString(&buf, ", "); + appendStringInfoString(&buf, "\"recovery_target_name\""); + } + val = GetConfigOption("recovery_target_time", false, false); + if (val[0] != '\0') + { + ntargets++; + if (buf.len > 0) + appendStringInfoString(&buf, ", "); + appendStringInfoString(&buf, "\"recovery_target_time\""); + } + val = GetConfigOption("recovery_target_xid", false, false); + if (val[0] != '\0') + { + ntargets++; + if (buf.len > 0) + appendStringInfoString(&buf, ", "); + appendStringInfoString(&buf, "\"recovery_target_xid\""); + } + + if (ntargets > 1) + ereport(FATAL, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("multiple recovery targets specified"), + errdetail("Recovery targets %s are set, but only one can be set.", + buf.data), + errhint("See pg_settings for the parameter values and where each is set."))); +} + /* * read_backup_label: check to see if a backup_label file is present * @@ -4769,32 +4861,27 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) } /* - * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable + * Recovery target settings: At most one of the several recovery_target* + * settings may be set to a non-empty value. The global variable * recoveryTarget tracks which kind of recovery target was chosen. Other * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. + * An empty string for any of these GUCs is treated as "not set", equivalent + * to the GUC's default; an empty value cannot clobber another GUC's + * already-set target. Conflicts between multiple non-empty settings are + * detected in CheckRecoveryTargetConflicts(), called from + * validateRecoveryParameters() at every startup. * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * Each assign hook clears recoveryTarget only when its own GUC is reassigned + * to an empty string after the same GUC was previously assigned a non-empty + * value, e.g. + * postgres -c recovery_target_xid=700 -c recovery_target_xid= + * (postgresql.conf collapses duplicate keys so only the last value reaches + * the assign hook; this same-parameter set-then-clear case only arises from + * -c). The clear is restricted to the hook's own target type so that an + * empty value for one recovery_target_* GUC cannot clobber another GUC's + * already-set target. */ -pg_noreturn static void -error_multiple_recovery_targets(void) -{ - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); -} - /* * GUC check_hook for recovery_target */ @@ -4815,13 +4902,9 @@ check_recovery_target(char **newval, void **extra, GucSource source) void assign_recovery_target(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_IMMEDIATE; - else + else if (recoveryTarget == RECOVERY_TARGET_IMMEDIATE) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4856,16 +4939,12 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_LSN; recoveryTargetLSN = *((XLogRecPtr *) extra); } - else + else if (recoveryTarget == RECOVERY_TARGET_LSN) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4891,16 +4970,12 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) void assign_recovery_target_name(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_NAME; recoveryTargetName = newval; } - else + else if (recoveryTarget == RECOVERY_TARGET_NAME) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4971,13 +5046,9 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) void assign_recovery_target_time(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_TIME; - else + else if (recoveryTarget == RECOVERY_TARGET_TIME) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -5099,15 +5170,11 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_XID; recoveryTargetXid = *((TransactionId *) extra); } - else + else if (recoveryTarget == RECOVERY_TARGET_XID) recoveryTarget = RECOVERY_TARGET_UNSET; } diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..6e8a2c557c9 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -51,6 +51,49 @@ sub test_recovery_standby return; } +# Start a standby with the given pg_ctl --options string and verify that +# the standby reaches the given LSN and row count. Used to exercise +# scenarios that require the postmaster command line to receive multiple +# "-c name=value" instances of the same GUC, which postgresql.conf cannot +# express because ProcessConfigFile collapses duplicate keys. +sub test_recovery_standby_with_options +{ + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my $test_name = shift; + my $node_name = shift; + my $node_primary = shift; + my $options = shift; + my $num_rows = shift; + my $until_lsn = shift; + + my $node_standby = PostgreSQL::Test::Cluster->new($node_name); + $node_standby->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + + my $res = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_standby->data_dir, + '--log' => $node_standby->logfile, + '--options' => $options, + 'start', + ]); + ok($res, "server starts for $test_name"); + + $node_standby->poll_query_until('postgres', + "SELECT '$until_lsn'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; + + my $count = $node_standby->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); + is($count, qq($num_rows), "check standby content for $test_name"); + + $node_standby->teardown_node; + + return; +} + # Initialize primary node my $node_primary = PostgreSQL::Test::Cluster->new('primary'); $node_primary->init(has_archiving => 1, allows_streaming => 1); @@ -108,6 +151,13 @@ $node_primary->safe_psql('postgres', # Force archiving of WAL file $node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); +# LSN after the final 6000-row insert and WAL switch. Used by the +# set-then-cleared scenarios below where recovery has no target and must +# replay all archived WAL; polling on $lsn5 would race against the 5001-6000 +# rows. +my $lsn6 = + $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); + # Test recovery targets my @recovery_params = ("recovery_target = 'immediate'"); test_recovery_standby('immediate target', @@ -125,11 +175,24 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params, test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, "5000", $lsn5); +# Regression: empty-string for one recovery_target_* GUC must not clobber +# another non-empty target. Setting recovery_target_xid + recovery_target_time +# = '' must recover to the xid, not run as no-target recovery. +@recovery_params = ( + "recovery_target_xid = '$recovery_txid'", + "recovery_target_time = ''"); +test_recovery_standby('xid with empty time GUC', + 'standby_xid_empty_time', $node_primary, \@recovery_params, + "2000", $lsn2); + # Multiple targets # -# Multiple conflicting settings are not allowed, but setting the same -# parameter multiple times or unsetting a parameter and setting a -# different one is allowed. +# Multiple conflicting non-empty settings are not allowed. Setting the same +# parameter multiple times is allowed (the last value wins, per ProcessConfigFile +# duplicate handling). An empty string for a recovery_target_* GUC is treated +# as the GUC's default and is a no-op; it does not clear any other already-set +# target. Conflict detection runs in CheckRecoveryTargetConflicts() at every +# server start, regardless of whether recovery is requested. @recovery_params = ( "recovery_target_name = '$recovery_name'", @@ -159,6 +222,21 @@ like( $logfile, qr/multiple recovery targets specified/, 'multiple conflicting settings'); +# errdetail lists exactly the parameters that are set, in GUC-check order +# (recovery_target_name is checked before recovery_target_time). +like( + $logfile, + qr/Recovery targets "recovery_target_name", "recovery_target_time" are set/, + 'errdetail lists the set parameters in order'); +# The parameter values must not be echoed (only the names are listed). +unlike( + $logfile, + qr/are set[^\n]*=/, + 'errdetail does not echo parameter values'); +like( + $logfile, + qr/HINT:.*pg_settings/, + 'errhint points to pg_settings'); # Check behavior when recovery ends before target is reached @@ -190,6 +268,173 @@ like( qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicting recovery targets are rejected at every startup, regardless of +# whether recovery.signal is present. Use a throwaway cluster initialized +# from the existing backup so we can leave conflicting GUCs in its +# postgresql.conf without polluting the primary used by later tests. +# init_from_backup is called without has_restoring, so no recovery.signal +# is created and the cluster would normally start as a plain primary; the +# conflicting recovery_target_* GUCs must be rejected anyway. +my $node_no_signal = PostgreSQL::Test::Cluster->new('multi_target_no_signal'); +$node_no_signal->init_from_backup($node_primary, 'my_backup'); +$node_no_signal->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); + +my $res_no_signal = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_no_signal->data_dir, + '--log' => $node_no_signal->logfile, + 'start', + ]); +ok(!$res_no_signal, + 'server fails to start with conflicting recovery targets and no recovery.signal'); + +my $logfile_no_signal = slurp_file($node_no_signal->logfile()); +like( + $logfile_no_signal, + qr/multiple recovery targets specified/, + 'expected error message logged without recovery.signal'); +like( + $logfile_no_signal, + qr/Recovery targets "recovery_target_name", "recovery_target_time" are set/, + 'errdetail lists the set parameters in order without recovery.signal'); +unlike( + $logfile_no_signal, + qr/are set[^\n]*=/, + 'errdetail does not echo parameter values without recovery.signal'); +like( + $logfile_no_signal, + qr/HINT:.*pg_settings/, + 'errhint points to pg_settings without recovery.signal'); + +# Conflict involving the bare recovery_target (no suffix): it is checked first, +# so its name appears at the head of the list. The list entries are quoted, so +# the regex anchors on the closing quote to ensure "recovery_target" does not +# match a prefix of "recovery_target_xid". +my $node_bare = PostgreSQL::Test::Cluster->new('multi_target_bare'); +$node_bare->init_from_backup($node_primary, 'my_backup'); +$node_bare->append_conf('postgresql.conf', + "recovery_target = 'immediate'\nrecovery_target_xid = '$recovery_txid'"); + +my $res_bare = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_bare->data_dir, + '--log' => $node_bare->logfile, + 'start', + ]); +ok(!$res_bare, + 'server fails to start with bare recovery_target conflict'); + +my $logfile_bare = slurp_file($node_bare->logfile()); +like( + $logfile_bare, + qr/Recovery targets "recovery_target", "recovery_target_xid" are set/, + 'errdetail lists the bare recovery_target first, without prefix mismatch'); + +# Three conflicting targets: exercises the ", " separator between more than two +# entries (no trailing separator after the last entry). +my $node_three = PostgreSQL::Test::Cluster->new('multi_target_three'); +$node_three->init_from_backup($node_primary, 'my_backup'); +$node_three->append_conf('postgresql.conf', + "recovery_target_name = '$recovery_name'\n" + . "recovery_target_time = '$recovery_time'\n" + . "recovery_target_xid = '$recovery_txid'"); + +my $res_three = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_three->data_dir, + '--log' => $node_three->logfile, + 'start', + ]); +ok(!$res_three, + 'server fails to start with three conflicting recovery targets'); + +my $logfile_three = slurp_file($node_three->logfile()); +like( + $logfile_three, + qr/Recovery targets "recovery_target_name", "recovery_target_time", "recovery_target_xid" are set/, + 'errdetail lists three set parameters with correct separators'); + +# Same-GUC last-wins (one source of truth for the GUC's value): assigning a +# recovery_target_* GUC and then assigning the same GUC to an empty string +# leaves no target set and recovery proceeds to the end of WAL. This is the +# "set then unset" form of GUC reassignment; the assign hook clears its own +# type when the new value is empty. postgresql.conf cannot express duplicate +# keys (ProcessConfigFile collapses them), so the postmaster command line is +# used via "pg_ctl --options". Each of the five recovery_target_* assign +# hooks is exercised once. +# +# Note: GUC values below are passed unquoted on the command line. Windows +# cmd.exe does not strip single quotes the way POSIX shells do, so quoted +# values would reach the postmaster verbatim and be rejected. recovery_time +# from now() contains a space; we replace it with the ISO 8601 T separator +# so the value is a single shell token without quoting. +my $recovery_time_t = $recovery_time; +$recovery_time_t =~ s/ /T/; + +test_recovery_standby_with_options( + 'recovery_target_xid set then cleared', + 'standby_xid_set_clear', $node_primary, + "-c recovery_target_xid=$recovery_txid -c recovery_target_xid=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_time set then cleared', + 'standby_time_set_clear', $node_primary, + "-c recovery_target_time=$recovery_time_t -c recovery_target_time=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_name set then cleared', + 'standby_name_set_clear', $node_primary, + "-c recovery_target_name=$recovery_name -c recovery_target_name=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_lsn set then cleared', + 'standby_lsn_set_clear', $node_primary, + "-c recovery_target_lsn=$recovery_lsn -c recovery_target_lsn=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target set then cleared', + 'standby_immediate_set_clear', $node_primary, + "-c recovery_target=immediate -c recovery_target=", + "6000", $lsn6); + +# Same-GUC empty-then-set sanity: assigning an empty string and then a +# non-empty value to the same GUC must end with the target set. The empty +# value seen first must not poison a later non-empty assignment. +my $node_xid_clear_set = + PostgreSQL::Test::Cluster->new('standby_xid_clear_set'); +$node_xid_clear_set->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + +my $res_xid_clear_set = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_xid_clear_set->data_dir, + '--log' => $node_xid_clear_set->logfile, + '--options' => + "-c recovery_target_xid= -c recovery_target_xid=$recovery_txid", + 'start', + ]); +ok($res_xid_clear_set, + 'server starts with recovery_target_xid cleared then set'); + +$node_xid_clear_set->poll_query_until('postgres', + "SELECT '$lsn2'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; +my $count_xid_clear_set = $node_xid_clear_set->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); +is($count_xid_clear_set, "2000", + 'recovery_target_xid honored when cleared then set'); +$node_xid_clear_set->teardown_node; + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.52.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-21 12:31  Álvaro Herrera <alvherre@kurilemu.de>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Álvaro Herrera @ 2026-06-21 12:31 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Scott Ray <scott@scottray.io>; Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

Hello,

On 2026-Jun-21, JoongHyuk Shin wrote:

> The errdetail now lists which recovery_target_* parameters are actually set,
> instead of the full candidate list.
> This follows Scott's idea to surface the set targets;
> following Álvaro, the values are dropped
> and a new errhint points to pg_settings for them and their sources.
> 
> I kept the "which ones are set" list in errdetail rather than errhint,
> it states the current configuration, which reads as detail,
> while the errhint carries the actionable pg_settings pointer.

Please see https://postgr.es/c/3692a622d3fd for more on translatable
message construction.  You should end up with a translatable string in
a _() call like
  _(", \"%s\"")
and the GUC names in a separate string in each case.

Maybe you can make this a local macro to avoid repetitive coding,

#define considerAndComplainAboutGUC(gucname, buf) \
   do { \
       val = GetConfigOption(gucname, false, false); \
       if (val[0] != '\0')     \
       {                       \
            ntargets++;        \
            if (buf.len == 0)  \
                appendStringInfoString(&buf, _("\"%s\""), gucname); \
            else               \
                appendStringInfoString(&buf, _(", \"%s\""), gucname); \
       } \
   } while (0)

considerAndComplainAboutGUC("recovery_target", buf);
considerAndComplainAboutGUC("recovery_target_lsn", buf);
and so on.  (Of course, you should choose a less stupid macro name, but
you get my meaning.)

-- 
Álvaro Herrera         PostgreSQL Developer  —  https://www.EnterpriseDB.com/
"...  In accounting terms this makes perfect sense.  To rational humans, it
is insane.  Welcome to IBM."                           (Robert X. Cringely)
https://www.cringely.com/2015/06/03/autodesks-john-walker-explained-hp-and-ibm-in-1991/





^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-22 04:37  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Álvaro Herrera <alvherre@kurilemu.de>
  0 siblings, 2 replies; 45+ messages in thread

From: JoongHyuk Shin @ 2026-06-22 04:37 UTC (permalink / raw)
  To: Álvaro Herrera <alvherre@kurilemu.de>; +Cc: Scott Ray <scott@scottray.io>; Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

Thanks for the review.

v6 attached.

The set-parameter list in the errdetail now wraps the separator and quotes
in _()
so the punctuation is translatable, following 3692a622d3fd.
The five GetConfigOption() checks are now a local macro, as suggested.

-- 
JH Shin

On Sun, Jun 21, 2026 at 9:32 PM Álvaro Herrera <alvherre@kurilemu.de> wrote:

> Hello,
>
> On 2026-Jun-21, JoongHyuk Shin wrote:
>
> > The errdetail now lists which recovery_target_* parameters are actually
> set,
> > instead of the full candidate list.
> > This follows Scott's idea to surface the set targets;
> > following Álvaro, the values are dropped
> > and a new errhint points to pg_settings for them and their sources.
> >
> > I kept the "which ones are set" list in errdetail rather than errhint,
> > it states the current configuration, which reads as detail,
> > while the errhint carries the actionable pg_settings pointer.
>
> Please see https://postgr.es/c/3692a622d3fd for more on translatable
> message construction.  You should end up with a translatable string in
> a _() call like
>   _(", \"%s\"")
> and the GUC names in a separate string in each case.
>
> Maybe you can make this a local macro to avoid repetitive coding,
>
> #define considerAndComplainAboutGUC(gucname, buf) \
>    do { \
>        val = GetConfigOption(gucname, false, false); \
>        if (val[0] != '\0')     \
>        {                       \
>             ntargets++;        \
>             if (buf.len == 0)  \
>                 appendStringInfoString(&buf, _("\"%s\""), gucname); \
>             else               \
>                 appendStringInfoString(&buf, _(", \"%s\""), gucname); \
>        } \
>    } while (0)
>
> considerAndComplainAboutGUC("recovery_target", buf);
> considerAndComplainAboutGUC("recovery_target_lsn", buf);
> and so on.  (Of course, you should choose a less stupid macro name, but
> you get my meaning.)
>
> --
> Álvaro Herrera         PostgreSQL Developer  —
> https://www.EnterpriseDB.com/
> "...  In accounting terms this makes perfect sense.  To rational humans, it
> is insane.  Welcome to IBM."                           (Robert X. Cringely)
>
> https://www.cringely.com/2015/06/03/autodesks-john-walker-explained-hp-and-ibm-in-1991/
>

Attachments:

v6-0001-Don-t-call-ereport-ERROR-from-recovery-target-GUC.patch (application/octet-stream, 22.0K) download From 7f05600fb76699a09daa671ddf4d5c85d1c944d9 Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Sun, 21 Jun 2026 17:45:25 +0900 Subject: [PATCH v6] Don't call ereport(ERROR) from recovery target GUC assign hooks The five recovery target GUC assign hooks (assign_recovery_target, assign_recovery_target_lsn, assign_recovery_target_name, assign_recovery_target_time, assign_recovery_target_xid) all called error_multiple_recovery_targets(), which invoked ereport(ERROR). The GUC README explicitly states that assign hooks must never fail; raising an error from an assign hook leaves guc.c's internal state inconsistent before the abort. A comment in the code itself acknowledged this as "broken by design." Fix this by removing the conflict check from all five assign hooks and detecting multiple recovery targets in a new CheckRecoveryTargetConflicts() function, called from validateRecoveryParameters() before its early return for !ArchiveRecoveryRequested. The check therefore runs at every startup regardless of recovery mode, preserving the existing behavior of detecting misconfiguration at server start time rather than only when recovery.signal is added. In each assign hook's empty-string branch, replace the unconditional "recoveryTarget = RECOVERY_TARGET_UNSET" assignment with a narrower "if (recoveryTarget == MY_TYPE)" form. Empty strings for an unrelated recovery_target_* GUC then leave another GUC's already-set target intact, while still preserving the documented "last value wins" reassignment semantics for the same GUC. When a conflict is detected, the errdetail now lists which recovery_target_* parameters are actually set, rather than the full list of candidate names, and an errhint points to pg_settings for their values and where each is configured. --- src/backend/access/transam/xlogrecovery.c | 140 +++++++---- src/test/recovery/t/003_recovery_targets.pl | 251 +++++++++++++++++++- 2 files changed, 342 insertions(+), 49 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 4d61795b483..32d209b3957 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -61,6 +61,7 @@ #include "storage/subsystems.h" #include "utils/datetime.h" #include "utils/fmgrprotos.h" +#include "utils/guc.h" #include "utils/guc_hooks.h" #include "utils/pgstat_internal.h" #include "utils/pg_lsn.h" @@ -341,6 +342,7 @@ static void ApplyWalRecord(XLogReaderState *xlogreader, XLogRecord *record, Time static void EnableStandbyMode(void); static void readRecoverySignalFile(void); static void validateRecoveryParameters(void); +static void CheckRecoveryTargetConflicts(void); static bool read_backup_label(XLogRecPtr *checkPointLoc, TimeLineID *backupLabelTLI, bool *backupEndRequired, bool *backupFromStandby); @@ -1067,6 +1069,8 @@ readRecoverySignalFile(void) static void validateRecoveryParameters(void) { + CheckRecoveryTargetConflicts(); + if (!ArchiveRecoveryRequested) return; @@ -1144,6 +1148,75 @@ validateRecoveryParameters(void) } } +/* + * CheckRecoveryTargetConflicts + * + * Validate that at most one of the recovery_target_* GUCs is set to a + * non-empty value. This is called from validateRecoveryParameters() at every + * server startup, regardless of whether archive recovery is requested, so + * misconfiguration is detected at server start time rather than later when + * recovery.signal is added. + * + * The check is a separate function rather than inlined into + * validateRecoveryParameters() because it intentionally runs even when no + * recovery is requested, while the rest of validateRecoveryParameters() is + * recovery-mode-only. Keeping it as a named function makes that separation + * explicit. + * + * The check used to live in the assign hooks of the recovery_target_* GUCs + * (calling ereport(ERROR) on conflict), which violated guc.c's contract that + * assign hooks must never fail. Moving the check here keeps the assign hooks + * contract-compliant. + * + * If a future patch adds a sixth recovery_target_* GUC, it must be added to the + * list of ADD_TARGET_IF_SET() calls below; the errdetail builds its list of set + * parameters dynamically, so it needs no change. + */ +static void +CheckRecoveryTargetConflicts(void) +{ + int ntargets = 0; + const char *val; + StringInfoData buf; + + initStringInfo(&buf); + + /* + * These GUCs are all PGC_STRING, so GetConfigOption() returns "" (not + * NULL) when a parameter is unset. Collect the name of each one that is + * set into buf for use in the errdetail below. The separator and the + * quotes are wrapped in _() so translators can adapt the list punctuation + * to their locale. + */ +#define ADD_TARGET_IF_SET(gucname) \ + do { \ + val = GetConfigOption(gucname, false, false); \ + if (val[0] != '\0') \ + { \ + ntargets++; \ + if (buf.len == 0) \ + appendStringInfo(&buf, _("\"%s\""), gucname); \ + else \ + appendStringInfo(&buf, _(", \"%s\""), gucname); \ + } \ + } while (0) + + ADD_TARGET_IF_SET("recovery_target"); + ADD_TARGET_IF_SET("recovery_target_lsn"); + ADD_TARGET_IF_SET("recovery_target_name"); + ADD_TARGET_IF_SET("recovery_target_time"); + ADD_TARGET_IF_SET("recovery_target_xid"); +#undef ADD_TARGET_IF_SET + + if (ntargets > 1) + ereport(FATAL, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("multiple recovery targets specified"), + errdetail("Recovery targets %s are set, but only one can be set.", + buf.data), + errhint("See pg_settings for the parameter values and where each is set."))); +} + /* * read_backup_label: check to see if a backup_label file is present * @@ -4769,32 +4842,27 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) } /* - * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable + * Recovery target settings: At most one of the several recovery_target* + * settings may be set to a non-empty value. The global variable * recoveryTarget tracks which kind of recovery target was chosen. Other * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. + * An empty string for any of these GUCs is treated as "not set", equivalent + * to the GUC's default; an empty value cannot clobber another GUC's + * already-set target. Conflicts between multiple non-empty settings are + * detected in CheckRecoveryTargetConflicts(), called from + * validateRecoveryParameters() at every startup. * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * Each assign hook clears recoveryTarget only when its own GUC is reassigned + * to an empty string after the same GUC was previously assigned a non-empty + * value, e.g. + * postgres -c recovery_target_xid=700 -c recovery_target_xid= + * (postgresql.conf collapses duplicate keys so only the last value reaches + * the assign hook; this same-parameter set-then-clear case only arises from + * -c). The clear is restricted to the hook's own target type so that an + * empty value for one recovery_target_* GUC cannot clobber another GUC's + * already-set target. */ -pg_noreturn static void -error_multiple_recovery_targets(void) -{ - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); -} - /* * GUC check_hook for recovery_target */ @@ -4815,13 +4883,9 @@ check_recovery_target(char **newval, void **extra, GucSource source) void assign_recovery_target(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_IMMEDIATE; - else + else if (recoveryTarget == RECOVERY_TARGET_IMMEDIATE) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4856,16 +4920,12 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_LSN; recoveryTargetLSN = *((XLogRecPtr *) extra); } - else + else if (recoveryTarget == RECOVERY_TARGET_LSN) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4891,16 +4951,12 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) void assign_recovery_target_name(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_NAME; recoveryTargetName = newval; } - else + else if (recoveryTarget == RECOVERY_TARGET_NAME) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -4971,13 +5027,9 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) void assign_recovery_target_time(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) recoveryTarget = RECOVERY_TARGET_TIME; - else + else if (recoveryTarget == RECOVERY_TARGET_TIME) recoveryTarget = RECOVERY_TARGET_UNSET; } @@ -5099,15 +5151,11 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) { recoveryTarget = RECOVERY_TARGET_XID; recoveryTargetXid = *((TransactionId *) extra); } - else + else if (recoveryTarget == RECOVERY_TARGET_XID) recoveryTarget = RECOVERY_TARGET_UNSET; } diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..6e8a2c557c9 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -51,6 +51,49 @@ sub test_recovery_standby return; } +# Start a standby with the given pg_ctl --options string and verify that +# the standby reaches the given LSN and row count. Used to exercise +# scenarios that require the postmaster command line to receive multiple +# "-c name=value" instances of the same GUC, which postgresql.conf cannot +# express because ProcessConfigFile collapses duplicate keys. +sub test_recovery_standby_with_options +{ + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my $test_name = shift; + my $node_name = shift; + my $node_primary = shift; + my $options = shift; + my $num_rows = shift; + my $until_lsn = shift; + + my $node_standby = PostgreSQL::Test::Cluster->new($node_name); + $node_standby->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + + my $res = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_standby->data_dir, + '--log' => $node_standby->logfile, + '--options' => $options, + 'start', + ]); + ok($res, "server starts for $test_name"); + + $node_standby->poll_query_until('postgres', + "SELECT '$until_lsn'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; + + my $count = $node_standby->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); + is($count, qq($num_rows), "check standby content for $test_name"); + + $node_standby->teardown_node; + + return; +} + # Initialize primary node my $node_primary = PostgreSQL::Test::Cluster->new('primary'); $node_primary->init(has_archiving => 1, allows_streaming => 1); @@ -108,6 +151,13 @@ $node_primary->safe_psql('postgres', # Force archiving of WAL file $node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); +# LSN after the final 6000-row insert and WAL switch. Used by the +# set-then-cleared scenarios below where recovery has no target and must +# replay all archived WAL; polling on $lsn5 would race against the 5001-6000 +# rows. +my $lsn6 = + $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); + # Test recovery targets my @recovery_params = ("recovery_target = 'immediate'"); test_recovery_standby('immediate target', @@ -125,11 +175,24 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params, test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, "5000", $lsn5); +# Regression: empty-string for one recovery_target_* GUC must not clobber +# another non-empty target. Setting recovery_target_xid + recovery_target_time +# = '' must recover to the xid, not run as no-target recovery. +@recovery_params = ( + "recovery_target_xid = '$recovery_txid'", + "recovery_target_time = ''"); +test_recovery_standby('xid with empty time GUC', + 'standby_xid_empty_time', $node_primary, \@recovery_params, + "2000", $lsn2); + # Multiple targets # -# Multiple conflicting settings are not allowed, but setting the same -# parameter multiple times or unsetting a parameter and setting a -# different one is allowed. +# Multiple conflicting non-empty settings are not allowed. Setting the same +# parameter multiple times is allowed (the last value wins, per ProcessConfigFile +# duplicate handling). An empty string for a recovery_target_* GUC is treated +# as the GUC's default and is a no-op; it does not clear any other already-set +# target. Conflict detection runs in CheckRecoveryTargetConflicts() at every +# server start, regardless of whether recovery is requested. @recovery_params = ( "recovery_target_name = '$recovery_name'", @@ -159,6 +222,21 @@ like( $logfile, qr/multiple recovery targets specified/, 'multiple conflicting settings'); +# errdetail lists exactly the parameters that are set, in GUC-check order +# (recovery_target_name is checked before recovery_target_time). +like( + $logfile, + qr/Recovery targets "recovery_target_name", "recovery_target_time" are set/, + 'errdetail lists the set parameters in order'); +# The parameter values must not be echoed (only the names are listed). +unlike( + $logfile, + qr/are set[^\n]*=/, + 'errdetail does not echo parameter values'); +like( + $logfile, + qr/HINT:.*pg_settings/, + 'errhint points to pg_settings'); # Check behavior when recovery ends before target is reached @@ -190,6 +268,173 @@ like( qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicting recovery targets are rejected at every startup, regardless of +# whether recovery.signal is present. Use a throwaway cluster initialized +# from the existing backup so we can leave conflicting GUCs in its +# postgresql.conf without polluting the primary used by later tests. +# init_from_backup is called without has_restoring, so no recovery.signal +# is created and the cluster would normally start as a plain primary; the +# conflicting recovery_target_* GUCs must be rejected anyway. +my $node_no_signal = PostgreSQL::Test::Cluster->new('multi_target_no_signal'); +$node_no_signal->init_from_backup($node_primary, 'my_backup'); +$node_no_signal->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); + +my $res_no_signal = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_no_signal->data_dir, + '--log' => $node_no_signal->logfile, + 'start', + ]); +ok(!$res_no_signal, + 'server fails to start with conflicting recovery targets and no recovery.signal'); + +my $logfile_no_signal = slurp_file($node_no_signal->logfile()); +like( + $logfile_no_signal, + qr/multiple recovery targets specified/, + 'expected error message logged without recovery.signal'); +like( + $logfile_no_signal, + qr/Recovery targets "recovery_target_name", "recovery_target_time" are set/, + 'errdetail lists the set parameters in order without recovery.signal'); +unlike( + $logfile_no_signal, + qr/are set[^\n]*=/, + 'errdetail does not echo parameter values without recovery.signal'); +like( + $logfile_no_signal, + qr/HINT:.*pg_settings/, + 'errhint points to pg_settings without recovery.signal'); + +# Conflict involving the bare recovery_target (no suffix): it is checked first, +# so its name appears at the head of the list. The list entries are quoted, so +# the regex anchors on the closing quote to ensure "recovery_target" does not +# match a prefix of "recovery_target_xid". +my $node_bare = PostgreSQL::Test::Cluster->new('multi_target_bare'); +$node_bare->init_from_backup($node_primary, 'my_backup'); +$node_bare->append_conf('postgresql.conf', + "recovery_target = 'immediate'\nrecovery_target_xid = '$recovery_txid'"); + +my $res_bare = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_bare->data_dir, + '--log' => $node_bare->logfile, + 'start', + ]); +ok(!$res_bare, + 'server fails to start with bare recovery_target conflict'); + +my $logfile_bare = slurp_file($node_bare->logfile()); +like( + $logfile_bare, + qr/Recovery targets "recovery_target", "recovery_target_xid" are set/, + 'errdetail lists the bare recovery_target first, without prefix mismatch'); + +# Three conflicting targets: exercises the ", " separator between more than two +# entries (no trailing separator after the last entry). +my $node_three = PostgreSQL::Test::Cluster->new('multi_target_three'); +$node_three->init_from_backup($node_primary, 'my_backup'); +$node_three->append_conf('postgresql.conf', + "recovery_target_name = '$recovery_name'\n" + . "recovery_target_time = '$recovery_time'\n" + . "recovery_target_xid = '$recovery_txid'"); + +my $res_three = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_three->data_dir, + '--log' => $node_three->logfile, + 'start', + ]); +ok(!$res_three, + 'server fails to start with three conflicting recovery targets'); + +my $logfile_three = slurp_file($node_three->logfile()); +like( + $logfile_three, + qr/Recovery targets "recovery_target_name", "recovery_target_time", "recovery_target_xid" are set/, + 'errdetail lists three set parameters with correct separators'); + +# Same-GUC last-wins (one source of truth for the GUC's value): assigning a +# recovery_target_* GUC and then assigning the same GUC to an empty string +# leaves no target set and recovery proceeds to the end of WAL. This is the +# "set then unset" form of GUC reassignment; the assign hook clears its own +# type when the new value is empty. postgresql.conf cannot express duplicate +# keys (ProcessConfigFile collapses them), so the postmaster command line is +# used via "pg_ctl --options". Each of the five recovery_target_* assign +# hooks is exercised once. +# +# Note: GUC values below are passed unquoted on the command line. Windows +# cmd.exe does not strip single quotes the way POSIX shells do, so quoted +# values would reach the postmaster verbatim and be rejected. recovery_time +# from now() contains a space; we replace it with the ISO 8601 T separator +# so the value is a single shell token without quoting. +my $recovery_time_t = $recovery_time; +$recovery_time_t =~ s/ /T/; + +test_recovery_standby_with_options( + 'recovery_target_xid set then cleared', + 'standby_xid_set_clear', $node_primary, + "-c recovery_target_xid=$recovery_txid -c recovery_target_xid=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_time set then cleared', + 'standby_time_set_clear', $node_primary, + "-c recovery_target_time=$recovery_time_t -c recovery_target_time=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_name set then cleared', + 'standby_name_set_clear', $node_primary, + "-c recovery_target_name=$recovery_name -c recovery_target_name=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target_lsn set then cleared', + 'standby_lsn_set_clear', $node_primary, + "-c recovery_target_lsn=$recovery_lsn -c recovery_target_lsn=", + "6000", $lsn6); + +test_recovery_standby_with_options( + 'recovery_target set then cleared', + 'standby_immediate_set_clear', $node_primary, + "-c recovery_target=immediate -c recovery_target=", + "6000", $lsn6); + +# Same-GUC empty-then-set sanity: assigning an empty string and then a +# non-empty value to the same GUC must end with the target set. The empty +# value seen first must not poison a later non-empty assignment. +my $node_xid_clear_set = + PostgreSQL::Test::Cluster->new('standby_xid_clear_set'); +$node_xid_clear_set->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + +my $res_xid_clear_set = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_xid_clear_set->data_dir, + '--log' => $node_xid_clear_set->logfile, + '--options' => + "-c recovery_target_xid= -c recovery_target_xid=$recovery_txid", + 'start', + ]); +ok($res_xid_clear_set, + 'server starts with recovery_target_xid cleared then set'); + +$node_xid_clear_set->poll_query_until('postgres', + "SELECT '$lsn2'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; +my $count_xid_clear_set = $node_xid_clear_set->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); +is($count_xid_clear_set, "2000", + 'recovery_target_xid honored when cleared then set'); +$node_xid_clear_set->teardown_node; + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.52.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-25 08:00  Michael Paquier <michael@paquier.xyz>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  1 sibling, 1 reply; 45+ messages in thread

From: Michael Paquier @ 2026-06-25 08:00 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Álvaro Herrera <alvherre@kurilemu.de>; Scott Ray <scott@scottray.io>; Fujii Masao <masao.fujii@gmail.com>; pgsql-hackers@lists.postgresql.org

On Mon, Jun 22, 2026 at 01:37:27PM +0900, JoongHyuk Shin wrote:
> Thanks for the review.

Please avoid top-posting when replying on the lists.  This breaks the
logical flow of the thread.

> The five GetConfigOption() checks are now a local macro, as suggested.

+                (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+                 errmsg("multiple recovery targets specified"),
+                 errdetail("Recovery targets %s are set, but only one can be set.",
+                           buf.data),

Is this errdetail() the optimal choice, though?  It feels a bit odd to
have that mid-sentence.  Perhaps we should use a colon to separate the
list of parameters, say simply:
"Parameters set are: %s."

The trick of Alvaro to make the list of parameters translatable is
interesting.  Didn't know that.

The tests feel bloated to me, bumping the time it takes to run 003 by
30%~40% or so.  The "Three conflicting targets" has for example little
value.  I think that we should also get rid of most of the tests where
we do the patterns for "-c recovery_target_foo=bar -c
recovery_target_foo=", repeated for each target type.  One should be
enough.  Keeping only one failure case should be enough for two
recovery targets (or all of them set, why not).

With the first tests in place, I am not convinced that the
anti-pattern "-c recovery_target_foo= -c recovery_target_foo=bar" is 
needed.  Let's just remove it.

This code is clearly AI-generated.  Comments are equally bloated with
descriptions that can be understood just by reading the code.  Let's
simplify all that.

In CheckRecoveryTargetConflicts(), initStringInfo() does an
allocation.  Perhaps we should free it after making sure we don't
FATAL.  Just a good practice.
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-26 07:15  Kyotaro Horiguchi <horikyota.ntt@gmail.com>
  parent: Michael Paquier <michael@paquier.xyz>
  0 siblings, 1 reply; 45+ messages in thread

From: Kyotaro Horiguchi @ 2026-06-26 07:15 UTC (permalink / raw)
  To: michael@paquier.xyz; +Cc: sjh910805@gmail.com; alvherre@kurilemu.de; scott@scottray.io; masao.fujii@gmail.com; pgsql-hackers@lists.postgresql.org

Hello,

I spent some time thinking about this issue and the proposed patch,
and I wonder if we can simplify it a bit further.

If we don't want to rely on GUC hooks for cross-checking multiple
recovery target settings, perhaps the assign hooks could stop updating
the shared recoveryTarget state altogether and only maintain their own
underlying variables.

As far as I can tell, the existing recovery target variables already
seem sufficient to determine whether each target is configured. The
only exception appears to be recovery_target = 'immediate', which
would probably need an additional boolean flag (or similar state) to
represent whether it has been specified.

Then validateRecoveryParameters() could determine which recovery
target is configured by inspecting those variables directly, report an
error if more than one target is set, and reconstruct the same
recoveryTarget state that the current code expects.

Does that make sense?

Regards,

-- 
Kyotaro Horiguchi
NTT Open Source Software Center





^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-26 08:12  Michael Paquier <michael@paquier.xyz>
  parent: Kyotaro Horiguchi <horikyota.ntt@gmail.com>
  0 siblings, 0 replies; 45+ messages in thread

From: Michael Paquier @ 2026-06-26 08:12 UTC (permalink / raw)
  To: Kyotaro Horiguchi <horikyota.ntt@gmail.com>; +Cc: sjh910805@gmail.com; alvherre@kurilemu.de; scott@scottray.io; masao.fujii@gmail.com; pgsql-hackers@lists.postgresql.org

On Fri, Jun 26, 2026 at 04:15:53PM +0900, Kyotaro Horiguchi wrote:
> As far as I can tell, the existing recovery target variables already
> seem sufficient to determine whether each target is configured. The
> only exception appears to be recovery_target = 'immediate', which
> would probably need an additional boolean flag (or similar state) to
> represent whether it has been specified.
> 
> Then validateRecoveryParameters() could determine which recovery
> target is configured by inspecting those variables directly, report an
> error if more than one target is set, and reconstruct the same
> recoveryTarget state that the current code expects.

And delay setting recoveryTarget at all until we enter the validation
step in the startup process?  We don't use it in any of the early
steps before entering validateRecoveryParameters() (if we do so, that
would be an incorrect thing to do anyway).

I don't see on top of my mind why that would not work, skipping all
the RECOVERY_TARGET_UNSET manipulations in the assign hooks.  One
source of the confusion, to me, is that we have taken the decision to
link directly recoveryTarget with the result of the GUC, while
recoveryTarget is also used to track if the other target GUCs are set.
It looks to me that we shouldn't use recoveryTarget to track both if a
target is set and if the immediate state is set, and split both
things.  This is what I guess you are getting at with your extra
boolean.  Let's call it a new recoveryTargetImmediate.

At the end recoveryTarget should never be touched in any of the GUC
hooks, just once we enter validateRecoveryParameters().
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-26 11:12  Henson Choi <assam258@gmail.com>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  1 sibling, 1 reply; 45+ messages in thread

From: Henson Choi @ 2026-06-26 11:12 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Álvaro Herrera <alvherre@kurilemu.de>; Scott Ray <scott@scottray.io>; Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

Hi hackers,

Thanks for v6.  I think there is still one reachable case on the
command-line (-c) path where the conflict detection is bypassed and a
recovery target is silently dropped, so recovery runs to the end of WAL
instead of stopping at the requested target.  On master the same input
is rejected at startup, so for the -c path this is a loud-reject ->
silent behavior change.

Reproduction
------------

Using --check so it is side-effect-free; the conflict is detected during
GUC assignment, so an actual startup behaves the same:

    postgres --check -D data \
        -c "recovery_target_xid=700" \
        -c "recovery_target_name=foo" \
        -c "recovery_target_name="

  master rejects it at the second assignment:

      FATAL:  multiple recovery targets specified
      DETAIL:  At most one of "recovery_target", "recovery_target_lsn",
      "recovery_target_name", "recovery_target_time", "recovery_target_xid"
      may be set.

  v6 passes the check with no error (exit 0); recoveryTarget ends up
  RECOVERY_TARGET_UNSET.  In an actual PITR this means
  recovery_target_xid=700 is silently ignored and the server recovers to
  the end of WAL (target overshoot).

postgresql.conf does not reproduce this: duplicate keys are collapsed
there, so only the final value reaches the assign hook.  The
set-then-clear of a *second*, different target only happens via -c,
where each option is applied in order and fires the assign hook every
time.

Why it happens
--------------

recoveryTarget is a single enum that the per-GUC assign hooks maintain
incrementally, and it alone drives recovery.  CheckRecoveryTargetConflicts()
independently re-reads the committed GUC strings via GetConfigOption().
With the three -c options above the two sides diverge:

    step        assign hooks -> recoveryTarget      final strings
    xid=700     XID                                 xid="700"
    name=foo    NAME (overwrites XID)               xid="700", name="foo"
    name=""     UNSET (NAME was the last type)      xid="700", name=""

At "name=foo" two targets are momentarily set, but the single enum
cannot represent that, so it just overwrites; "name=" then clears it.
By the time CheckRecoveryTargetConflicts() runs, the strings have
settled to a single non-empty target (xid), so ntargets == 1 and the
check passes -- while recovery actually runs with recoveryTarget ==
UNSET.

In other words, the v6 matrix row "cross-GUC, both non-empty -> error"
no longer holds once the second GUC is subsequently cleared: the
set-time guard was removed, and the startup check only inspects the
final strings, which no longer show the transient conflict.  This is the
same class of unexpected behavior reported earlier in this thread; the
three-step -c form is the variant that still slips through.

One option would be to derive recoveryTarget from the settled GUC strings
in CheckRecoveryTargetConflicts() instead of maintaining it incrementally
in the assign hooks, but any other approach is perfectly fine.

Minor nit
---------

While here: the new ereport() in CheckRecoveryTargetConflicts() still
wraps its arguments in the legacy extra parentheses,

    ereport(FATAL,
            (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
             errmsg("multiple recovery targets specified"),
             ...));

Since the parenthesis-free ereport() form is available, new code can drop
them:

    ereport(FATAL,
            errcode(ERRCODE_INVALID_PARAMETER_VALUE),
            errmsg("multiple recovery targets specified"),
            ...);

Regards,
Henson

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-29 04:45  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Henson Choi <assam258@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-06-29 04:45 UTC (permalink / raw)
  To: assam258@gmail.com; +Cc: Álvaro Herrera <alvherre@kurilemu.de>; Scott Ray <scott@scottray.io>; Fujii Masao <masao.fujii@gmail.com>; Michael Paquier <michael@paquier.xyz>; pgsql-hackers@lists.postgresql.org

Thanks for the reviews.

v7 attached, reworked along the lines discussed. The assign hooks
no longer touch recoveryTarget; each just stores its own value,
and recoveryTarget is derived once in validateRecoveryParameters()
from the settled recovery_target* settings,
which also rejects setting more than one target.
The v6 review comments are folded in too.

> recovery_target_xid=700 is silently ignored ... target overshoot
> derive recoveryTarget from the settled GUC strings

Done that way. A new test, standby_clobber_clear, sets a competing target
and clears it again; on master that is rejected outright,
while here recovery stops at the xid.

I left out the extra boolean. recovery_target's own string is "immediate"
or empty, so immediate is detected like the other four.
Deriving it from the typed value variables would need the flag,
but those aren't reliable "is set" signals anyway,
since the time value is parsed late and an LSN of 0/0 is valid.
Happy to add it back if you prefer it explicit.

> the legacy extra parentheses in ereport()

Dropped.

-- 
JH Shin

Attachments:

v7-0001-Don-t-call-ereport-ERROR-from-recovery-target-GUC.patch (application/octet-stream, 17.6K) download From 2c0daee6be5ffdb2ac48e4da687247480ebfb8dc Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Sun, 28 Jun 2026 18:25:45 +0900 Subject: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks A GUC assign hook must not raise an error, but the recovery_target* assign hooks did so when a second target was set. Make the assign hooks store only their own value, and derive recoveryTarget once in validateRecoveryParameters() from the settled recovery_target* values, rejecting there a configuration that sets more than one target. --- src/backend/access/transam/xlogrecovery.c | 142 ++++++++----------- src/backend/utils/misc/guc_parameters.dat | 2 - src/include/utils/guc_hooks.h | 2 - src/test/recovery/t/003_recovery_targets.pl | 149 ++++++++++++++++---- 4 files changed, 183 insertions(+), 112 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index c0ae4d3f63f..5c8d019c7bb 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -61,6 +61,7 @@ #include "storage/subsystems.h" #include "utils/datetime.h" #include "utils/fmgrprotos.h" +#include "utils/guc.h" #include "utils/guc_hooks.h" #include "utils/pgstat_internal.h" #include "utils/pg_lsn.h" @@ -341,6 +342,7 @@ static void ApplyWalRecord(XLogReaderState *xlogreader, XLogRecord *record, Time static void EnableStandbyMode(void); static void readRecoverySignalFile(void); static void validateRecoveryParameters(void); +static RecoveryTargetType DetermineRecoveryTargetType(void); static bool read_backup_label(XLogRecPtr *checkPointLoc, TimeLineID *backupLabelTLI, bool *backupEndRequired, bool *backupFromStandby); @@ -1067,6 +1069,14 @@ readRecoverySignalFile(void) static void validateRecoveryParameters(void) { + /* + * Derive recoveryTarget from the final recovery_target* settings, + * rejecting a configuration with more than one of them. This runs before + * the early return below so that conflicts are rejected at every startup, + * as the assign hooks used to do. + */ + recoveryTarget = DetermineRecoveryTargetType(); + if (!ArchiveRecoveryRequested) return; @@ -4769,30 +4779,59 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) } /* - * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable - * recoveryTarget tracks which kind of recovery target was chosen. Other - * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. - * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * Recovery target settings: at most one of the recovery_target* settings may + * be set. The assign hooks just store each parameter's own value; the chosen + * target and any conflict are derived here instead, from the final settings, + * because an assign hook must not raise an error and cannot see sibling GUCs. + * validateRecoveryParameters() calls this once after all GUC processing. */ - -pg_noreturn static void -error_multiple_recovery_targets(void) +static RecoveryTargetType +DetermineRecoveryTargetType(void) { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); + int ntargets = 0; + RecoveryTargetType target = RECOVERY_TARGET_UNSET; + const char *val; + StringInfoData buf; + + initStringInfo(&buf); + + /* + * These are all PGC_STRING, so GetConfigOption() returns "" (not NULL) + * when unset. The separators and quotes are wrapped in _() so + * translators can adapt the list punctuation. + */ +#define ADD_TARGET_IF_SET(gucname, kind) \ + do { \ + val = GetConfigOption(gucname, false, false); \ + if (val[0] != '\0') \ + { \ + ntargets++; \ + target = (kind); \ + if (buf.len == 0) \ + appendStringInfo(&buf, _("\"%s\""), gucname); \ + else \ + appendStringInfo(&buf, _(", \"%s\""), gucname); \ + } \ + } while (0) + + ADD_TARGET_IF_SET("recovery_target", RECOVERY_TARGET_IMMEDIATE); + ADD_TARGET_IF_SET("recovery_target_lsn", RECOVERY_TARGET_LSN); + ADD_TARGET_IF_SET("recovery_target_name", RECOVERY_TARGET_NAME); + ADD_TARGET_IF_SET("recovery_target_time", RECOVERY_TARGET_TIME); + ADD_TARGET_IF_SET("recovery_target_xid", RECOVERY_TARGET_XID); +#undef ADD_TARGET_IF_SET + + if (ntargets > 1) + ereport(FATAL, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("multiple recovery targets specified"), + errdetail("Only one recovery target can be set. Parameters set: %s.", + buf.data), + errhint("See pg_settings for the parameter values and where each is set.")); + + pfree(buf.data); + + return target; } /* @@ -4809,22 +4848,6 @@ check_recovery_target(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target - */ -void -assign_recovery_target(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - recoveryTarget = RECOVERY_TARGET_IMMEDIATE; - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_lsn */ @@ -4856,17 +4879,8 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_LSN; recoveryTargetLSN = *((XLogRecPtr *) extra); - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -4891,17 +4905,8 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) void assign_recovery_target_name(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_NAME; recoveryTargetName = newval; - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -4965,22 +4970,6 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target_time - */ -void -assign_recovery_target_time(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - recoveryTarget = RECOVERY_TARGET_TIME; - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_timeline */ @@ -5099,15 +5088,6 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_XID; recoveryTargetXid = *((TransactionId *) extra); - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index 3c1e6b31bf8..7bc967c629f 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2455,7 +2455,6 @@ variable => 'recovery_target_string', boot_val => '""', check_hook => 'check_recovery_target', - assign_hook => 'assign_recovery_target', }, { name => 'recovery_target_action', type => 'enum', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', @@ -2492,7 +2491,6 @@ variable => 'recovery_target_time_string', boot_val => '""', check_hook => 'check_recovery_target_time', - assign_hook => 'assign_recovery_target_time', }, { name => 'recovery_target_timeline', type => 'string', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', diff --git a/src/include/utils/guc_hooks.h b/src/include/utils/guc_hooks.h index 307f4fbaefe..1aec17c67bd 100644 --- a/src/include/utils/guc_hooks.h +++ b/src/include/utils/guc_hooks.h @@ -103,7 +103,6 @@ extern bool check_recovery_prefetch(int *new_value, void **extra, extern void assign_recovery_prefetch(int new_value, void *extra); extern bool check_recovery_target(char **newval, void **extra, GucSource source); -extern void assign_recovery_target(const char *newval, void *extra); extern bool check_recovery_target_lsn(char **newval, void **extra, GucSource source); extern void assign_recovery_target_lsn(const char *newval, void *extra); @@ -112,7 +111,6 @@ extern bool check_recovery_target_name(char **newval, void **extra, extern void assign_recovery_target_name(const char *newval, void *extra); extern bool check_recovery_target_time(char **newval, void **extra, GucSource source); -extern void assign_recovery_target_time(const char *newval, void *extra); extern bool check_recovery_target_timeline(char **newval, void **extra, GucSource source); extern void assign_recovery_target_timeline(const char *newval, void *extra); diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..f4d612e4263 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -51,6 +51,49 @@ sub test_recovery_standby return; } +# Start a standby with the given pg_ctl --options string and verify that +# the standby reaches the given LSN and row count. Used to exercise +# scenarios that require the postmaster command line to receive multiple +# "-c name=value" instances of the same GUC, which postgresql.conf cannot +# express because ProcessConfigFile collapses duplicate keys. +sub test_recovery_standby_with_options +{ + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my $test_name = shift; + my $node_name = shift; + my $node_primary = shift; + my $options = shift; + my $num_rows = shift; + my $until_lsn = shift; + + my $node_standby = PostgreSQL::Test::Cluster->new($node_name); + $node_standby->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + + my $res = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_standby->data_dir, + '--log' => $node_standby->logfile, + '--options' => $options, + 'start', + ]); + ok($res, "server starts for $test_name"); + + $node_standby->poll_query_until('postgres', + "SELECT '$until_lsn'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; + + my $count = $node_standby->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); + is($count, qq($num_rows), "check standby content for $test_name"); + + $node_standby->teardown_node; + + return; +} + # Initialize primary node my $node_primary = PostgreSQL::Test::Cluster->new('primary'); $node_primary->init(has_archiving => 1, allows_streaming => 1); @@ -108,6 +151,12 @@ $node_primary->safe_psql('postgres', # Force archiving of WAL file $node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); +# LSN after the final 6000-row insert and WAL switch. The set-then-clear case +# below has no recovery target and replays all WAL, so it polls on this instead +# of $lsn5, which would race the 5001-6000 rows. +my $lsn6 = + $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); + # Test recovery targets my @recovery_params = ("recovery_target = 'immediate'"); test_recovery_standby('immediate target', @@ -125,11 +174,22 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params, test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, "5000", $lsn5); +# Regression: empty-string for one recovery_target_* GUC must not clobber +# another non-empty target. Setting recovery_target_xid + recovery_target_time +# = '' must recover to the xid, not run as no-target recovery. +@recovery_params = ( + "recovery_target_xid = '$recovery_txid'", + "recovery_target_time = ''"); +test_recovery_standby('xid with empty time GUC', + 'standby_xid_empty_time', $node_primary, \@recovery_params, + "2000", $lsn2); + # Multiple targets # -# Multiple conflicting settings are not allowed, but setting the same -# parameter multiple times or unsetting a parameter and setting a -# different one is allowed. +# Multiple conflicting non-empty settings are rejected. Setting the same +# parameter twice is allowed (last value wins), and an empty string is a no-op +# that does not clear another GUC's target. Conflicts are detected at every +# server start by DetermineRecoveryTargetType(). @recovery_params = ( "recovery_target_name = '$recovery_name'", @@ -138,31 +198,9 @@ test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, test_recovery_standby('multiple overriding settings', 'standby_6', $node_primary, \@recovery_params, "3000", $lsn3); -my $node_standby = PostgreSQL::Test::Cluster->new('standby_7'); -$node_standby->init_from_backup($node_primary, 'my_backup', - has_restoring => 1); -$node_standby->append_conf( - 'postgresql.conf', "recovery_target_name = '$recovery_name' -recovery_target_time = '$recovery_time'"); - -my $res = run_log( - [ - 'pg_ctl', - '--pgdata' => $node_standby->data_dir, - '--log' => $node_standby->logfile, - 'start', - ]); -ok(!$res, 'invalid recovery startup fails'); - -my $logfile = slurp_file($node_standby->logfile()); -like( - $logfile, - qr/multiple recovery targets specified/, - 'multiple conflicting settings'); - # Check behavior when recovery ends before target is reached -$node_standby = PostgreSQL::Test::Cluster->new('standby_8'); +my $node_standby = PostgreSQL::Test::Cluster->new('standby_8'); $node_standby->init_from_backup( $node_primary, 'my_backup', has_restoring => 1, @@ -184,12 +222,69 @@ foreach my $i (0 .. 10 * $PostgreSQL::Test::Utils::timeout_default) last if !-f $node_standby->data_dir . '/postmaster.pid'; usleep(100_000); } -$logfile = slurp_file($node_standby->logfile()); +my $logfile = slurp_file($node_standby->logfile()); like( $logfile, qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicts are rejected at every startup, even without recovery.signal. +# init_from_backup without has_restoring creates no recovery.signal, so this +# cluster would otherwise start as a plain primary; the conflict must still be +# caught. +my $node_no_signal = PostgreSQL::Test::Cluster->new('multi_target_no_signal'); +$node_no_signal->init_from_backup($node_primary, 'my_backup'); +$node_no_signal->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); + +my $res_no_signal = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_no_signal->data_dir, + '--log' => $node_no_signal->logfile, + 'start', + ]); +ok(!$res_no_signal, + 'server fails to start with conflicting recovery targets and no recovery.signal'); + +my $logfile_no_signal = slurp_file($node_no_signal->logfile()); +like( + $logfile_no_signal, + qr/multiple recovery targets specified/, + 'expected error message logged without recovery.signal'); +like( + $logfile_no_signal, + qr/Only one recovery target can be set\. Parameters set: "recovery_target_name", "recovery_target_time"/, + 'errdetail lists the set parameters in order without recovery.signal'); +unlike( + $logfile_no_signal, + qr/Parameters set:[^\n]*=/, + 'errdetail does not echo parameter values without recovery.signal'); +like( + $logfile_no_signal, + qr/HINT:.*pg_settings/, + 'errhint points to pg_settings without recovery.signal'); + +# Same-GUC set-then-clear: setting a recovery_target_* GUC and then setting the +# same GUC to an empty string leaves no target, so recovery runs to the end of +# WAL. Duplicate keys collapse in postgresql.conf, so "pg_ctl --options" passes +# both assignments on the postmaster command line. +test_recovery_standby_with_options( + 'recovery_target_xid set then cleared', + 'standby_xid_set_clear', $node_primary, + "-c recovery_target_xid=$recovery_txid -c recovery_target_xid=", + "6000", $lsn6); + +# Set recovery_target_xid, then set and clear recovery_target_name. Only the +# xid remains, so recovery must stop at it rather than running to the end of WAL +# (a competing target that is set then cleared must not strand the first one). +test_recovery_standby_with_options( + 'recovery target preserved when a competing one is set then cleared', + 'standby_clobber_clear', $node_primary, + "-c recovery_target_xid=$recovery_txid -c recovery_target_name=$recovery_name -c recovery_target_name=", + "2000", $lsn2); + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.52.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-29 05:47  Michael Paquier <michael@paquier.xyz>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 2 replies; 45+ messages in thread

From: Michael Paquier @ 2026-06-29 05:47 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: assam258@gmail.com, Álvaro Herrera <alvherre@kurilemu.de>; Scott Ray <scott@scottray.io>; Fujii Masao <masao.fujii@gmail.com>; pgsql-hackers@lists.postgresql.org

On Mon, Jun 29, 2026 at 01:45:37PM +0900, JoongHyuk Shin wrote:
> v7 attached, reworked along the lines discussed. The assign hooks
> no longer touch recoveryTarget; each just stores its own value,
> and recoveryTarget is derived once in validateRecoveryParameters()
> from the settled recovery_target* settings,
> which also rejects setting more than one target.
> The v6 review comments are folded in too.

Hmm.  One aspect of the patch that I have a hard time accepting is
this aspect:
ADD_TARGET_IF_SET("recovery_target", RECOVERY_TARGET_IMMEDIATE);

This part lacks future extensibility, IMO.  If we add more values to
the GUC recovery_target in the future, we push down more complication
to the resolution of the immediate target at the beginning of the
startup process.  Decoupling entirely RecoveryTargetType and the types
of values that can be assigned in the GUC recovery_target may lead to
a nicer result, I suspect..  It also feels wasteful to add an
hypothetical enum for the supported values if we don't have a use for 
it yet.  Any opinions from others?

+       errdetail("Only one recovery target can be set.  Parameters set: %s.",
+                  buf.data),
+       errhint("See pg_settings for the parameter values and where each is set."));

pg_settings is just one way to look at these values.  We have also
SHOW and other interfaces.  I would keep the errdetail() with your
first sentence, make the errhint the second sentence of the
errdetail().
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-29 07:59  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Michael Paquier <michael@paquier.xyz>
  1 sibling, 0 replies; 45+ messages in thread

From: JoongHyuk Shin @ 2026-06-29 07:59 UTC (permalink / raw)
  To: Michael Paquier <michael@paquier.xyz>; +Cc: assam258@gmail.com, Álvaro Herrera <alvherre@kurilemu.de>; Scott Ray <scott@scottray.io>; Fujii Masao <masao.fujii@gmail.com>; pgsql-hackers@lists.postgresql.org

On Mon, Jun 29, 2026 at 2:48 PM Michael Paquier <michael@paquier.xyz> wrote:
> pg_settings is just one way to look at these values. We have also
> SHOW and other interfaces. I would keep the errdetail() with your
> first sentence, make the errhint the second sentence of the errdetail().

I split it along the message style guide's line, factual information
in the detail and suggestions in the hint. The detail states the facts,
which targets are set, and the "see pg_settings" pointer is guidance
for tracking them down rather than a fact about the conflict,
so it went into the hint.
I'd keep it there rather than fold it into the detail.

SHOW does return the values, but I named pg_settings
specifically because it also reports where each parameter is set,
through its sourcefile and sourceline columns.
With several recovery_target* values possibly spread
across configuration files, finding where each one is set
is usually what it takes to resolve the conflict,
which is the part SHOW leaves out.

-- 
JH Shin

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-29 21:16  Zsolt Parragi <zsolt.parragi@percona.com>
  parent: Michael Paquier <michael@paquier.xyz>
  1 sibling, 1 reply; 45+ messages in thread

From: Zsolt Parragi @ 2026-06-29 21:16 UTC (permalink / raw)
  To: pgsql-hackers@lists.postgresql.org

On Mon, 29 Jun 2026, Michael Paquier <michael@paquier.xyz> wrote:
> +       errdetail("Only one recovery target can be set.  Parameters set: %s.",
> +                  buf.data),
> +       errhint("See pg_settings for the parameter values and where each is set."));
>
> pg_settings is just one way to look at these values. We have also
> SHOW and other interfaces. I would keep the errdetail() with your
> first sentence, make the errhint the second sentence of the
> errdetail().

Isn't mentioning pg_settings confusing instead of helpful during a
server restart? With a reload it can help, but when the server can't
start, hinting that the user should query pg_settings doesn't seem
that useful.





^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-06-29 23:35  Michael Paquier <michael@paquier.xyz>
  parent: Zsolt Parragi <zsolt.parragi@percona.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Michael Paquier @ 2026-06-29 23:35 UTC (permalink / raw)
  To: Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: pgsql-hackers@lists.postgresql.org

On Mon, Jun 29, 2026 at 04:16:59PM -0500, Zsolt Parragi wrote:
> Isn't mentioning pg_settings confusing instead of helpful during a
> server restart? With a reload it can help, but when the server can't
> start, hinting that the user should query pg_settings doesn't seem
> that useful.

Yeah, it is.  I don't see a reason why we should be specific about the
location where these parameters are set.  One has been setting up
recovery parameters in the GUC machine, so they most likely know where
these strings are.
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-06 07:53  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Michael Paquier <michael@paquier.xyz>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-07-06 07:53 UTC (permalink / raw)
  To: Michael Paquier <michael@paquier.xyz>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Mon, Jun 29, 2026 at 04:16:59PM -0500, Zsolt Parragi wrote:
> Isn't mentioning pg_settings confusing instead of helpful during a
> server restart? With a reload it can help, but when the server can't
> start, hinting that the user should query pg_settings doesn't seem
> that useful.

You're right, and I had missed this.  Thanks.

I would rather drop the errhint from this patch entirely than fold it into
the errdetail.
Any objections?

-- 
JH Shin

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-06 22:30  Michael Paquier <michael@paquier.xyz>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Michael Paquier @ 2026-07-06 22:30 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Mon, Jul 06, 2026 at 04:53:44PM +0900, JoongHyuk Shin wrote:
> I would rather drop the errhint from this patch entirely than fold it into
> the errdetail.
> Any objections?

Sounds good here to remove the errhint().  No hint is better than a
potentially wrong hint.  Thanks.
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-07 03:01  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Michael Paquier <michael@paquier.xyz>
  0 siblings, 2 replies; 45+ messages in thread

From: JoongHyuk Shin @ 2026-07-07 03:01 UTC (permalink / raw)
  To: Michael Paquier <michael@paquier.xyz>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

Attached v8 with the errhint removed.  No other changes from v7.

-- 
JH Shin

Attachments:

v8-0001-Don-t-call-ereport-ERROR-from-recovery-target-GUC.patch (application/octet-stream, 17.4K) download From decdc03a2177ed31b5bbb464fa8c67d3f6d922bf Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Sun, 28 Jun 2026 18:25:45 +0900 Subject: [PATCH v8] Don't call ereport(ERROR) from recovery target GUC assign hooks A GUC assign hook must not raise an error, but the recovery_target* assign hooks did so when a second target was set. Make the assign hooks store only their own value, and derive recoveryTarget once in validateRecoveryParameters() from the settled recovery_target* values, rejecting there a configuration that sets more than one target. --- src/backend/access/transam/xlogrecovery.c | 141 ++++++++----------- src/backend/utils/misc/guc_parameters.dat | 2 - src/include/utils/guc_hooks.h | 2 - src/test/recovery/t/003_recovery_targets.pl | 145 ++++++++++++++++---- 4 files changed, 178 insertions(+), 112 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index c0ae4d3f63f..e0c9d306e6d 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -61,6 +61,7 @@ #include "storage/subsystems.h" #include "utils/datetime.h" #include "utils/fmgrprotos.h" +#include "utils/guc.h" #include "utils/guc_hooks.h" #include "utils/pgstat_internal.h" #include "utils/pg_lsn.h" @@ -341,6 +342,7 @@ static void ApplyWalRecord(XLogReaderState *xlogreader, XLogRecord *record, Time static void EnableStandbyMode(void); static void readRecoverySignalFile(void); static void validateRecoveryParameters(void); +static RecoveryTargetType DetermineRecoveryTargetType(void); static bool read_backup_label(XLogRecPtr *checkPointLoc, TimeLineID *backupLabelTLI, bool *backupEndRequired, bool *backupFromStandby); @@ -1067,6 +1069,14 @@ readRecoverySignalFile(void) static void validateRecoveryParameters(void) { + /* + * Derive recoveryTarget from the final recovery_target* settings, + * rejecting a configuration with more than one of them. This runs before + * the early return below so that conflicts are rejected at every startup, + * as the assign hooks used to do. + */ + recoveryTarget = DetermineRecoveryTargetType(); + if (!ArchiveRecoveryRequested) return; @@ -4769,30 +4779,58 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) } /* - * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable - * recoveryTarget tracks which kind of recovery target was chosen. Other - * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. - * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * Recovery target settings: at most one of the recovery_target* settings may + * be set. The assign hooks just store each parameter's own value; the chosen + * target and any conflict are derived here instead, from the final settings, + * because an assign hook must not raise an error and cannot see sibling GUCs. + * validateRecoveryParameters() calls this once after all GUC processing. */ - -pg_noreturn static void -error_multiple_recovery_targets(void) +static RecoveryTargetType +DetermineRecoveryTargetType(void) { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); + int ntargets = 0; + RecoveryTargetType target = RECOVERY_TARGET_UNSET; + const char *val; + StringInfoData buf; + + initStringInfo(&buf); + + /* + * These are all PGC_STRING, so GetConfigOption() returns "" (not NULL) + * when unset. The separators and quotes are wrapped in _() so + * translators can adapt the list punctuation. + */ +#define ADD_TARGET_IF_SET(gucname, kind) \ + do { \ + val = GetConfigOption(gucname, false, false); \ + if (val[0] != '\0') \ + { \ + ntargets++; \ + target = (kind); \ + if (buf.len == 0) \ + appendStringInfo(&buf, _("\"%s\""), gucname); \ + else \ + appendStringInfo(&buf, _(", \"%s\""), gucname); \ + } \ + } while (0) + + ADD_TARGET_IF_SET("recovery_target", RECOVERY_TARGET_IMMEDIATE); + ADD_TARGET_IF_SET("recovery_target_lsn", RECOVERY_TARGET_LSN); + ADD_TARGET_IF_SET("recovery_target_name", RECOVERY_TARGET_NAME); + ADD_TARGET_IF_SET("recovery_target_time", RECOVERY_TARGET_TIME); + ADD_TARGET_IF_SET("recovery_target_xid", RECOVERY_TARGET_XID); +#undef ADD_TARGET_IF_SET + + if (ntargets > 1) + ereport(FATAL, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("multiple recovery targets specified"), + errdetail("Only one recovery target can be set. Parameters set: %s.", + buf.data)); + + pfree(buf.data); + + return target; } /* @@ -4809,22 +4847,6 @@ check_recovery_target(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target - */ -void -assign_recovery_target(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - recoveryTarget = RECOVERY_TARGET_IMMEDIATE; - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_lsn */ @@ -4856,17 +4878,8 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_LSN; recoveryTargetLSN = *((XLogRecPtr *) extra); - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -4891,17 +4904,8 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) void assign_recovery_target_name(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_NAME; recoveryTargetName = newval; - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -4965,22 +4969,6 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target_time - */ -void -assign_recovery_target_time(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - recoveryTarget = RECOVERY_TARGET_TIME; - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_timeline */ @@ -5099,15 +5087,6 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_XID; recoveryTargetXid = *((TransactionId *) extra); - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index 3c1e6b31bf8..7bc967c629f 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2455,7 +2455,6 @@ variable => 'recovery_target_string', boot_val => '""', check_hook => 'check_recovery_target', - assign_hook => 'assign_recovery_target', }, { name => 'recovery_target_action', type => 'enum', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', @@ -2492,7 +2491,6 @@ variable => 'recovery_target_time_string', boot_val => '""', check_hook => 'check_recovery_target_time', - assign_hook => 'assign_recovery_target_time', }, { name => 'recovery_target_timeline', type => 'string', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', diff --git a/src/include/utils/guc_hooks.h b/src/include/utils/guc_hooks.h index 307f4fbaefe..1aec17c67bd 100644 --- a/src/include/utils/guc_hooks.h +++ b/src/include/utils/guc_hooks.h @@ -103,7 +103,6 @@ extern bool check_recovery_prefetch(int *new_value, void **extra, extern void assign_recovery_prefetch(int new_value, void *extra); extern bool check_recovery_target(char **newval, void **extra, GucSource source); -extern void assign_recovery_target(const char *newval, void *extra); extern bool check_recovery_target_lsn(char **newval, void **extra, GucSource source); extern void assign_recovery_target_lsn(const char *newval, void *extra); @@ -112,7 +111,6 @@ extern bool check_recovery_target_name(char **newval, void **extra, extern void assign_recovery_target_name(const char *newval, void *extra); extern bool check_recovery_target_time(char **newval, void **extra, GucSource source); -extern void assign_recovery_target_time(const char *newval, void *extra); extern bool check_recovery_target_timeline(char **newval, void **extra, GucSource source); extern void assign_recovery_target_timeline(const char *newval, void *extra); diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..19b25368089 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -51,6 +51,49 @@ sub test_recovery_standby return; } +# Start a standby with the given pg_ctl --options string and verify that +# the standby reaches the given LSN and row count. Used to exercise +# scenarios that require the postmaster command line to receive multiple +# "-c name=value" instances of the same GUC, which postgresql.conf cannot +# express because ProcessConfigFile collapses duplicate keys. +sub test_recovery_standby_with_options +{ + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my $test_name = shift; + my $node_name = shift; + my $node_primary = shift; + my $options = shift; + my $num_rows = shift; + my $until_lsn = shift; + + my $node_standby = PostgreSQL::Test::Cluster->new($node_name); + $node_standby->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + + my $res = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_standby->data_dir, + '--log' => $node_standby->logfile, + '--options' => $options, + 'start', + ]); + ok($res, "server starts for $test_name"); + + $node_standby->poll_query_until('postgres', + "SELECT '$until_lsn'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; + + my $count = $node_standby->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); + is($count, qq($num_rows), "check standby content for $test_name"); + + $node_standby->teardown_node; + + return; +} + # Initialize primary node my $node_primary = PostgreSQL::Test::Cluster->new('primary'); $node_primary->init(has_archiving => 1, allows_streaming => 1); @@ -108,6 +151,12 @@ $node_primary->safe_psql('postgres', # Force archiving of WAL file $node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); +# LSN after the final 6000-row insert and WAL switch. The set-then-clear case +# below has no recovery target and replays all WAL, so it polls on this instead +# of $lsn5, which would race the 5001-6000 rows. +my $lsn6 = + $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); + # Test recovery targets my @recovery_params = ("recovery_target = 'immediate'"); test_recovery_standby('immediate target', @@ -125,11 +174,22 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params, test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, "5000", $lsn5); +# Regression: empty-string for one recovery_target_* GUC must not clobber +# another non-empty target. Setting recovery_target_xid + recovery_target_time +# = '' must recover to the xid, not run as no-target recovery. +@recovery_params = ( + "recovery_target_xid = '$recovery_txid'", + "recovery_target_time = ''"); +test_recovery_standby('xid with empty time GUC', + 'standby_xid_empty_time', $node_primary, \@recovery_params, + "2000", $lsn2); + # Multiple targets # -# Multiple conflicting settings are not allowed, but setting the same -# parameter multiple times or unsetting a parameter and setting a -# different one is allowed. +# Multiple conflicting non-empty settings are rejected. Setting the same +# parameter twice is allowed (last value wins), and an empty string is a no-op +# that does not clear another GUC's target. Conflicts are detected at every +# server start by DetermineRecoveryTargetType(). @recovery_params = ( "recovery_target_name = '$recovery_name'", @@ -138,31 +198,9 @@ test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, test_recovery_standby('multiple overriding settings', 'standby_6', $node_primary, \@recovery_params, "3000", $lsn3); -my $node_standby = PostgreSQL::Test::Cluster->new('standby_7'); -$node_standby->init_from_backup($node_primary, 'my_backup', - has_restoring => 1); -$node_standby->append_conf( - 'postgresql.conf', "recovery_target_name = '$recovery_name' -recovery_target_time = '$recovery_time'"); - -my $res = run_log( - [ - 'pg_ctl', - '--pgdata' => $node_standby->data_dir, - '--log' => $node_standby->logfile, - 'start', - ]); -ok(!$res, 'invalid recovery startup fails'); - -my $logfile = slurp_file($node_standby->logfile()); -like( - $logfile, - qr/multiple recovery targets specified/, - 'multiple conflicting settings'); - # Check behavior when recovery ends before target is reached -$node_standby = PostgreSQL::Test::Cluster->new('standby_8'); +my $node_standby = PostgreSQL::Test::Cluster->new('standby_8'); $node_standby->init_from_backup( $node_primary, 'my_backup', has_restoring => 1, @@ -184,12 +222,65 @@ foreach my $i (0 .. 10 * $PostgreSQL::Test::Utils::timeout_default) last if !-f $node_standby->data_dir . '/postmaster.pid'; usleep(100_000); } -$logfile = slurp_file($node_standby->logfile()); +my $logfile = slurp_file($node_standby->logfile()); like( $logfile, qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicts are rejected at every startup, even without recovery.signal. +# init_from_backup without has_restoring creates no recovery.signal, so this +# cluster would otherwise start as a plain primary; the conflict must still be +# caught. +my $node_no_signal = PostgreSQL::Test::Cluster->new('multi_target_no_signal'); +$node_no_signal->init_from_backup($node_primary, 'my_backup'); +$node_no_signal->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); + +my $res_no_signal = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_no_signal->data_dir, + '--log' => $node_no_signal->logfile, + 'start', + ]); +ok(!$res_no_signal, + 'server fails to start with conflicting recovery targets and no recovery.signal'); + +my $logfile_no_signal = slurp_file($node_no_signal->logfile()); +like( + $logfile_no_signal, + qr/multiple recovery targets specified/, + 'expected error message logged without recovery.signal'); +like( + $logfile_no_signal, + qr/Only one recovery target can be set\. Parameters set: "recovery_target_name", "recovery_target_time"/, + 'errdetail lists the set parameters in order without recovery.signal'); +unlike( + $logfile_no_signal, + qr/Parameters set:[^\n]*=/, + 'errdetail does not echo parameter values without recovery.signal'); + +# Same-GUC set-then-clear: setting a recovery_target_* GUC and then setting the +# same GUC to an empty string leaves no target, so recovery runs to the end of +# WAL. Duplicate keys collapse in postgresql.conf, so "pg_ctl --options" passes +# both assignments on the postmaster command line. +test_recovery_standby_with_options( + 'recovery_target_xid set then cleared', + 'standby_xid_set_clear', $node_primary, + "-c recovery_target_xid=$recovery_txid -c recovery_target_xid=", + "6000", $lsn6); + +# Set recovery_target_xid, then set and clear recovery_target_name. Only the +# xid remains, so recovery must stop at it rather than running to the end of WAL +# (a competing target that is set then cleared must not strand the first one). +test_recovery_standby_with_options( + 'recovery target preserved when a competing one is set then cleared', + 'standby_clobber_clear', $node_primary, + "-c recovery_target_xid=$recovery_txid -c recovery_target_name=$recovery_name -c recovery_target_name=", + "2000", $lsn2); + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.52.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-07 06:58  Michael Paquier <michael@paquier.xyz>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  1 sibling, 0 replies; 45+ messages in thread

From: Michael Paquier @ 2026-07-07 06:58 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Tue, Jul 07, 2026 at 12:01:59PM +0900, JoongHyuk Shin wrote:
> Attached v8 with the errhint removed.  No other changes from v7.

+               errmsg("multiple recovery targets specified"),
+               errdetail("Only one recovery target can be set. Parameters set: %s.",
+                         buf.data));

FWIW, I think that this is redundant, as the errmsg and the errdetail
are basically saying the same thing.  I'd suggest a simpler:
errmsg: cannot specify more than one recovery target
errdetail: Parameters set are: %s.

+# that does not clear another GUC's target.  Conflicts are detected at every
+# server start by DetermineRecoveryTargetType().

We don't really care about the function name here, just that multiple
targets are blocked.

+# LSN after the final 6000-row insert and WAL switch.  The set-then-clear case
+# below has no recovery target and replays all WAL, so it polls on this instead
+# of $lsn5, which would race the 5001-6000 rows.

I smell of an AI set of comments.  We could just remove the whole and
not lose value in understanding the meaning of the test.  A bunch of
the comments added to the TAP script could also be trimmed down quite
a bit, made simpler..

With recovery_target assign hook being removed for the case of
immediate, a test case that checks for a conflict between immediate
and a secondary target may be in order.

Please note that Fujii-san is registered as a committer of this patch,
so I am not planning to go beyond a review here.
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-07 06:59  Michael Paquier <michael@paquier.xyz>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  1 sibling, 1 reply; 45+ messages in thread

From: Michael Paquier @ 2026-07-07 06:59 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Tue, Jul 07, 2026 at 12:01:59PM +0900, JoongHyuk Shin wrote:
> Attached v8 with the errhint removed.  No other changes from v7.

+               errmsg("multiple recovery targets specified"),
+               errdetail("Only one recovery target can be set. Parameters set: %s.",
+                         buf.data));

FWIW, I think that this is redundant, as the errmsg and the errdetail
are basically saying the same thing.  I'd suggest a simpler:
errmsg: cannot specify more than one recovery target
errdetail: Parameters set are: %s.

+# that does not clear another GUC's target.  Conflicts are detected at every
+# server start by DetermineRecoveryTargetType().

We don't really care about the function name here, just that multiple
targets are blocked.

+# LSN after the final 6000-row insert and WAL switch.  The set-then-clear case
+# below has no recovery target and replays all WAL, so it polls on this instead
+# of $lsn5, which would race the 5001-6000 rows.

I smell of an AI set of comments.  We could just remove the whole and
not lose value in understanding the meaning of the test.  A bunch of
the comments added to the TAP script could also be trimmed down quite
a bit, made simpler..

With recovery_target assign hook being removed for the case of
immediate, a test case that checks for a conflict between immediate
and a secondary target may be in order.

Please note that Fujii-san is registered as a committer of this patch,
so I am not planning to go beyond a review here.
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-08 01:38  Fujii Masao <masao.fujii@gmail.com>
  parent: Michael Paquier <michael@paquier.xyz>
  0 siblings, 1 reply; 45+ messages in thread

From: Fujii Masao @ 2026-07-08 01:38 UTC (permalink / raw)
  To: Michael Paquier <michael@paquier.xyz>; +Cc: JoongHyuk Shin <sjh910805@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Tue, Jul 7, 2026 at 3:59 PM Michael Paquier <michael@paquier.xyz> wrote:
>
> On Tue, Jul 07, 2026 at 12:01:59PM +0900, JoongHyuk Shin wrote:
> > Attached v8 with the errhint removed.  No other changes from v7.

We can remove assign_recovery_target_name()? With the v8 patch, it only
assigns the GUC string pointer to recoveryTargetName. It seems we could
instead have recovery_target_name store its value directly in
recoveryTargetName, remove recovery_target_name_string, and drop
the assign hook altogether. Thoughts?


> Please note that Fujii-san is registered as a committer of this patch,
> so I am not planning to go beyond a review here.

Thanks for the review! Yes, I will handle this patch.

Regards,

-- 
Fujii Masao





^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-08 01:43  Michael Paquier <michael@paquier.xyz>
  parent: Fujii Masao <masao.fujii@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Michael Paquier @ 2026-07-08 01:43 UTC (permalink / raw)
  To: Fujii Masao <masao.fujii@gmail.com>; +Cc: JoongHyuk Shin <sjh910805@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Wed, Jul 08, 2026 at 10:38:54AM +0900, Fujii Masao wrote:
> We can remove assign_recovery_target_name()? With the v8 patch, it only
> assigns the GUC string pointer to recoveryTargetName. It seems we could
> instead have recovery_target_name store its value directly in
> recoveryTargetName, remove recovery_target_name_string, and drop
> the assign hook altogether. Thoughts?

Yeah, perhaps we should just do that.  I've also found the consistency
with all these _string variables interesting to keep, but that's
minor compared to less code.
--
Michael

Attachments:

  signature.asc (application/pgp-signature, 832B) download

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-12 10:45  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Michael Paquier <michael@paquier.xyz>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-07-12 10:45 UTC (permalink / raw)
  To: Michael Paquier <michael@paquier.xyz>; +Cc: Fujii Masao <masao.fujii@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Wed, Jul 8, 2026 at 10:39 AM Fujii Masao <masao.fujii@gmail.com> wrote:
> We can remove assign_recovery_target_name()?

Yes, done in v9.  recovery_target_name now stores directly into
recoveryTargetName, so recovery_target_name_string and the assign hook
are both gone.

On Tue, Jul 7, 2026 at 3:59 PM Michael Paquier <michael@paquier.xyz> wrote:
> FWIW, I think that this is redundant, as the errmsg and the errdetail
> are basically saying the same thing.  I'd suggest a simpler:
> errmsg: cannot specify more than one recovery target
> errdetail: Parameters set are: %s.

Applied in v9.

> I smell of an AI set of comments.  We could just remove the whole and
> not lose value in understanding the meaning of the test.  A bunch of
> the comments added to the TAP script could also be trimmed down quite
> a bit, made simpler..

Done, and I trimmed the rest of the TAP comments too.

> With recovery_target assign hook being removed for the case of
> immediate, a test case that checks for a conflict between immediate
> and a secondary target may be in order.

Added in v9.

Thanks to you both for the reviews.

-- 
JH Shin

Attachments:

v9-0001-Don-t-call-ereport-ERROR-from-recovery-target-GUC.patch (application/octet-stream, 19.5K) download From ef7c377ea08d74c4cd279f507e4ab80afe6c5322 Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Sun, 28 Jun 2026 18:25:45 +0900 Subject: [PATCH v9] Don't call ereport(ERROR) from recovery target GUC assign hooks A GUC assign hook must not raise an error, but the recovery_target* assign hooks did so when a second target was set. Instead, derive recoveryTarget once in validateRecoveryParameters() from the settled recovery_target* values, rejecting there a configuration that sets more than one target. The assign hooks now only store each parameter's own value, and recovery_target_name, which needs no further processing, uses its GUC variable directly without an assign hook. --- src/backend/access/transam/xlogrecovery.c | 139 ++++++----------- src/backend/utils/misc/guc_parameters.dat | 5 +- src/backend/utils/misc/guc_tables.c | 1 - src/include/access/xlogrecovery.h | 2 +- src/include/utils/guc_hooks.h | 3 - src/test/recovery/t/003_recovery_targets.pl | 161 ++++++++++++++++---- 6 files changed, 183 insertions(+), 128 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index c0ae4d3f63f..e2ba09594dd 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -61,6 +61,7 @@ #include "storage/subsystems.h" #include "utils/datetime.h" #include "utils/fmgrprotos.h" +#include "utils/guc.h" #include "utils/guc_hooks.h" #include "utils/pgstat_internal.h" #include "utils/pg_lsn.h" @@ -92,7 +93,7 @@ int recoveryTargetAction = RECOVERY_TARGET_ACTION_PAUSE; TransactionId recoveryTargetXid; char *recovery_target_time_string; TimestampTz recoveryTargetTime; -const char *recoveryTargetName; +char *recoveryTargetName; XLogRecPtr recoveryTargetLSN; int recovery_min_apply_delay = 0; @@ -341,6 +342,7 @@ static void ApplyWalRecord(XLogReaderState *xlogreader, XLogRecord *record, Time static void EnableStandbyMode(void); static void readRecoverySignalFile(void); static void validateRecoveryParameters(void); +static RecoveryTargetType DetermineRecoveryTargetType(void); static bool read_backup_label(XLogRecPtr *checkPointLoc, TimeLineID *backupLabelTLI, bool *backupEndRequired, bool *backupFromStandby); @@ -1067,6 +1069,8 @@ readRecoverySignalFile(void) static void validateRecoveryParameters(void) { + recoveryTarget = DetermineRecoveryTargetType(); + if (!ArchiveRecoveryRequested) return; @@ -4769,30 +4773,50 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) } /* - * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable - * recoveryTarget tracks which kind of recovery target was chosen. Other - * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. - * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * Return the recovery target derived from the recovery_target* settings, + * raising an error if more than one of them is set. */ - -pg_noreturn static void -error_multiple_recovery_targets(void) +static RecoveryTargetType +DetermineRecoveryTargetType(void) { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); + int ntargets = 0; + RecoveryTargetType target = RECOVERY_TARGET_UNSET; + const char *val; + StringInfoData buf; + + initStringInfo(&buf); + +#define ADD_TARGET_IF_SET(gucname, kind) \ + do { \ + val = GetConfigOption(gucname, false, false); \ + if (val[0] != '\0') \ + { \ + ntargets++; \ + target = (kind); \ + if (buf.len == 0) \ + appendStringInfo(&buf, _("\"%s\""), gucname); \ + else \ + appendStringInfo(&buf, _(", \"%s\""), gucname); \ + } \ + } while (0) + + ADD_TARGET_IF_SET("recovery_target", RECOVERY_TARGET_IMMEDIATE); + ADD_TARGET_IF_SET("recovery_target_lsn", RECOVERY_TARGET_LSN); + ADD_TARGET_IF_SET("recovery_target_name", RECOVERY_TARGET_NAME); + ADD_TARGET_IF_SET("recovery_target_time", RECOVERY_TARGET_TIME); + ADD_TARGET_IF_SET("recovery_target_xid", RECOVERY_TARGET_XID); +#undef ADD_TARGET_IF_SET + + if (ntargets > 1) + ereport(FATAL, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("cannot specify more than one recovery target"), + errdetail("Parameters set are: %s.", + buf.data)); + + pfree(buf.data); + + return target; } /* @@ -4809,22 +4833,6 @@ check_recovery_target(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target - */ -void -assign_recovery_target(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - recoveryTarget = RECOVERY_TARGET_IMMEDIATE; - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_lsn */ @@ -4856,17 +4864,8 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_LSN; recoveryTargetLSN = *((XLogRecPtr *) extra); - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -4885,25 +4884,6 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target_name - */ -void -assign_recovery_target_name(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_NAME; - recoveryTargetName = newval; - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_time * @@ -4965,22 +4945,6 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target_time - */ -void -assign_recovery_target_time(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - recoveryTarget = RECOVERY_TARGET_TIME; - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_timeline */ @@ -5099,15 +5063,6 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_XID; recoveryTargetXid = *((TransactionId *) extra); - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index c9118e71988..191c69c5bc7 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2465,7 +2465,6 @@ variable => 'recovery_target_string', boot_val => '""', check_hook => 'check_recovery_target', - assign_hook => 'assign_recovery_target', }, { name => 'recovery_target_action', type => 'enum', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', @@ -2491,10 +2490,9 @@ { name => 'recovery_target_name', type => 'string', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', short_desc => 'Sets the named restore point up to which recovery will proceed.', - variable => 'recovery_target_name_string', + variable => 'recoveryTargetName', boot_val => '""', check_hook => 'check_recovery_target_name', - assign_hook => 'assign_recovery_target_name', }, { name => 'recovery_target_time', type => 'string', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', @@ -2502,7 +2500,6 @@ variable => 'recovery_target_time_string', boot_val => '""', check_hook => 'check_recovery_target_time', - assign_hook => 'assign_recovery_target_time', }, { name => 'recovery_target_timeline', type => 'string', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c index 90aa374b3ec..1ec460b6a82 100644 --- a/src/backend/utils/misc/guc_tables.c +++ b/src/backend/utils/misc/guc_tables.c @@ -667,7 +667,6 @@ static bool exec_backend_enabled = EXEC_BACKEND_ENABLED; static char *recovery_target_timeline_string; static char *recovery_target_string; static char *recovery_target_xid_string; -static char *recovery_target_name_string; static char *recovery_target_lsn_string; /* should be static, but commands/variable.c needs to get at this */ diff --git a/src/include/access/xlogrecovery.h b/src/include/access/xlogrecovery.h index ba7750dca0b..9ffd44fcbae 100644 --- a/src/include/access/xlogrecovery.h +++ b/src/include/access/xlogrecovery.h @@ -139,7 +139,7 @@ extern PGDLLIMPORT char *archiveCleanupCommand; extern PGDLLIMPORT TransactionId recoveryTargetXid; extern PGDLLIMPORT char *recovery_target_time_string; extern PGDLLIMPORT TimestampTz recoveryTargetTime; -extern PGDLLIMPORT const char *recoveryTargetName; +extern PGDLLIMPORT char *recoveryTargetName; extern PGDLLIMPORT XLogRecPtr recoveryTargetLSN; extern PGDLLIMPORT RecoveryTargetType recoveryTarget; extern PGDLLIMPORT bool wal_receiver_create_temp_slot; diff --git a/src/include/utils/guc_hooks.h b/src/include/utils/guc_hooks.h index 307f4fbaefe..6a76f8d5ed6 100644 --- a/src/include/utils/guc_hooks.h +++ b/src/include/utils/guc_hooks.h @@ -103,16 +103,13 @@ extern bool check_recovery_prefetch(int *new_value, void **extra, extern void assign_recovery_prefetch(int new_value, void *extra); extern bool check_recovery_target(char **newval, void **extra, GucSource source); -extern void assign_recovery_target(const char *newval, void *extra); extern bool check_recovery_target_lsn(char **newval, void **extra, GucSource source); extern void assign_recovery_target_lsn(const char *newval, void *extra); extern bool check_recovery_target_name(char **newval, void **extra, GucSource source); -extern void assign_recovery_target_name(const char *newval, void *extra); extern bool check_recovery_target_time(char **newval, void **extra, GucSource source); -extern void assign_recovery_target_time(const char *newval, void *extra); extern bool check_recovery_target_timeline(char **newval, void **extra, GucSource source); extern void assign_recovery_target_timeline(const char *newval, void *extra); diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..71281808983 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -51,6 +51,49 @@ sub test_recovery_standby return; } +# Start a standby with the given pg_ctl --options string and verify that +# the standby reaches the given LSN and row count. Used to exercise +# scenarios that require the postmaster command line to receive multiple +# "-c name=value" instances of the same GUC, which postgresql.conf cannot +# express because ProcessConfigFile collapses duplicate keys. +sub test_recovery_standby_with_options +{ + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my $test_name = shift; + my $node_name = shift; + my $node_primary = shift; + my $options = shift; + my $num_rows = shift; + my $until_lsn = shift; + + my $node_standby = PostgreSQL::Test::Cluster->new($node_name); + $node_standby->init_from_backup($node_primary, 'my_backup', + has_restoring => 1); + + my $res = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_standby->data_dir, + '--log' => $node_standby->logfile, + '--options' => $options, + 'start', + ]); + ok($res, "server starts for $test_name"); + + $node_standby->poll_query_until('postgres', + "SELECT '$until_lsn'::pg_lsn <= pg_last_wal_replay_lsn()") + or die "Timed out while waiting for standby to catch up"; + + my $count = $node_standby->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); + is($count, qq($num_rows), "check standby content for $test_name"); + + $node_standby->teardown_node; + + return; +} + # Initialize primary node my $node_primary = PostgreSQL::Test::Cluster->new('primary'); $node_primary->init(has_archiving => 1, allows_streaming => 1); @@ -108,6 +151,9 @@ $node_primary->safe_psql('postgres', # Force archiving of WAL file $node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); +my $lsn6 = + $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); + # Test recovery targets my @recovery_params = ("recovery_target = 'immediate'"); test_recovery_standby('immediate target', @@ -125,11 +171,20 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params, test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, "5000", $lsn5); +# Regression: empty-string for one recovery_target_* GUC must not clobber +# another non-empty target. Setting recovery_target_xid + recovery_target_time +# = '' must recover to the xid, not run as no-target recovery. +@recovery_params = ( + "recovery_target_xid = '$recovery_txid'", + "recovery_target_time = ''"); +test_recovery_standby('xid with empty time GUC', + 'standby_xid_empty_time', $node_primary, \@recovery_params, + "2000", $lsn2); + # Multiple targets # -# Multiple conflicting settings are not allowed, but setting the same -# parameter multiple times or unsetting a parameter and setting a -# different one is allowed. +# Multiple conflicting non-empty settings are rejected, but setting the same +# parameter twice or clearing one with an empty string is allowed. @recovery_params = ( "recovery_target_name = '$recovery_name'", @@ -138,31 +193,9 @@ test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, test_recovery_standby('multiple overriding settings', 'standby_6', $node_primary, \@recovery_params, "3000", $lsn3); -my $node_standby = PostgreSQL::Test::Cluster->new('standby_7'); -$node_standby->init_from_backup($node_primary, 'my_backup', - has_restoring => 1); -$node_standby->append_conf( - 'postgresql.conf', "recovery_target_name = '$recovery_name' -recovery_target_time = '$recovery_time'"); - -my $res = run_log( - [ - 'pg_ctl', - '--pgdata' => $node_standby->data_dir, - '--log' => $node_standby->logfile, - 'start', - ]); -ok(!$res, 'invalid recovery startup fails'); - -my $logfile = slurp_file($node_standby->logfile()); -like( - $logfile, - qr/multiple recovery targets specified/, - 'multiple conflicting settings'); - # Check behavior when recovery ends before target is reached -$node_standby = PostgreSQL::Test::Cluster->new('standby_8'); +my $node_standby = PostgreSQL::Test::Cluster->new('standby_8'); $node_standby->init_from_backup( $node_primary, 'my_backup', has_restoring => 1, @@ -184,12 +217,86 @@ foreach my $i (0 .. 10 * $PostgreSQL::Test::Utils::timeout_default) last if !-f $node_standby->data_dir . '/postmaster.pid'; usleep(100_000); } -$logfile = slurp_file($node_standby->logfile()); +my $logfile = slurp_file($node_standby->logfile()); like( $logfile, qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicts are rejected at every startup, even without recovery.signal. +# init_from_backup without has_restoring creates no recovery.signal, so this +# cluster would otherwise start as a plain primary; the conflict must still be +# caught. +my $node_no_signal = PostgreSQL::Test::Cluster->new('multi_target_no_signal'); +$node_no_signal->init_from_backup($node_primary, 'my_backup'); +$node_no_signal->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); + +my $res_no_signal = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_no_signal->data_dir, + '--log' => $node_no_signal->logfile, + 'start', + ]); +ok(!$res_no_signal, + 'server fails to start with conflicting recovery targets and no recovery.signal'); + +my $logfile_no_signal = slurp_file($node_no_signal->logfile()); +like( + $logfile_no_signal, + qr/cannot specify more than one recovery target/, + 'expected error message logged without recovery.signal'); +like( + $logfile_no_signal, + qr/Parameters set are: "recovery_target_name", "recovery_target_time"/, + 'errdetail lists the set parameters in order without recovery.signal'); +unlike( + $logfile_no_signal, + qr/Parameters set are:[^\n]*=/, + 'errdetail does not echo parameter values without recovery.signal'); + +my $node_immediate_conflict = + PostgreSQL::Test::Cluster->new('immediate_target_conflict'); +$node_immediate_conflict->init_from_backup($node_primary, 'my_backup'); +$node_immediate_conflict->append_conf('postgresql.conf', + "recovery_target = 'immediate' +recovery_target_xid = '$recovery_txid'"); + +my $res_immediate = run_log( + [ + 'pg_ctl', + '--pgdata' => $node_immediate_conflict->data_dir, + '--log' => $node_immediate_conflict->logfile, + 'start', + ]); +ok(!$res_immediate, + 'server fails to start with recovery_target=immediate and a second target'); +like( + slurp_file($node_immediate_conflict->logfile()), + qr/cannot specify more than one recovery target/, + 'recovery_target=immediate conflicting with another target is rejected'); + +# Same-GUC set-then-clear: setting a recovery_target_* GUC and then setting the +# same GUC to an empty string leaves no target, so recovery runs to the end of +# WAL. Duplicate keys collapse in postgresql.conf, so "pg_ctl --options" passes +# both assignments on the postmaster command line. +test_recovery_standby_with_options( + 'recovery_target_xid set then cleared', + 'standby_xid_set_clear', $node_primary, + "-c recovery_target_xid=$recovery_txid -c recovery_target_xid=", + "6000", $lsn6); + +# Set recovery_target_xid, then set and clear recovery_target_name. Only the +# xid remains, so recovery must stop at it rather than running to the end of WAL +# (a competing target that is set then cleared must not strand the first one). +test_recovery_standby_with_options( + 'recovery target preserved when a competing one is set then cleared', + 'standby_clobber_clear', $node_primary, + "-c recovery_target_xid=$recovery_txid -c recovery_target_name=$recovery_name -c recovery_target_name=", + "2000", $lsn2); + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.52.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-17 09:30  Fujii Masao <masao.fujii@gmail.com>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Fujii Masao @ 2026-07-17 09:30 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Sun, Jul 12, 2026 at 7:45 PM JoongHyuk Shin <sjh910805@gmail.com> wrote:
>
> On Wed, Jul 8, 2026 at 10:39 AM Fujii Masao <masao.fujii@gmail.com> wrote:
> > We can remove assign_recovery_target_name()?
>
> Yes, done in v9.  recovery_target_name now stores directly into
> recoveryTargetName, so recovery_target_name_string and the assign hook
> are both gone.

Thanks for updating the patch!

I've updated it further. Could you review this version?

+ my $res = run_log(
+ [
+ 'pg_ctl',
+ '--pgdata' => $node_standby->data_dir,
+ '--log' => $node_standby->logfile,
+ '--options' => $options,
+ 'start',
+ ]);
+ ok($res, "server starts for $test_name");

Starting a test node with pg_ctl directly bypasses Cluster.pm's PID tracking.
As a result, cleanup via stop(), teardown_node(), etc. may not work correctly,
and the postmaster can be left running if the test fails or exits unexpectedly.
To avoid that, I added a preliminary patch that allows
PostgreSQL::Test::Cluster::start() to pass extra postmaster options through
pg_ctl --options, and then updated the test to use that start().


+sub test_recovery_standby_with_options
+{

I merged test_recovery_standby_with_options() into the existing
test_recovery_standby() by adding an optional options parameter. This removes
duplicated standby setup, catch-up waiting, row-count checks, and teardown code.

Regards,

-- 
Fujii Masao

Attachments: download all (2 files, .tar.gz)

v10-0002-Avoid-ERROR-in-recovery-target-GUC-assign-hooks.patch (application/octet-stream, 19.5K) download From 7593ce45ffc014f01158092c4cfd76e7a7ff8835 Mon Sep 17 00:00:00 2001 From: JoongHyuk Shin <sjh910805@gmail.com> Date: Sun, 28 Jun 2026 18:25:45 +0900 Subject: [PATCH v10 2/2] Avoid ERROR in recovery target GUC assign hooks Recovery target parameters are postmaster-startup GUCs, but their assign hooks previously did more than assign individual parameter values. They also updated the global recoveryTarget state and raised ERROR if more than one recovery target appeared to be set. This was not a good fit for GUC assign hooks. Assign hooks should not throw ERROR, and deriving cross-parameter state while individual GUCs are still being assigned makes the result depend on assignment order rather than the final configuration. For example, setting one recovery target and then setting another recovery_target_* parameter to an empty string could clear recoveryTarget, causing recovery to proceed with no target even though a valid target remained configured. Fix this by having the assign hooks only store their own parameter values. The effective recoveryTarget is now derived once from the final recovery_target* settings in validateRecoveryParameters(), which also rejects configurations that specify more than one recovery target with FATAL. This preserves the expected behavior for repeated assignments of the same GUC, treats empty values as "not set", and removes cross-GUC validation from the assign hooks. --- src/backend/access/transam/xlogrecovery.c | 140 +++++++------------- src/backend/utils/misc/guc_parameters.dat | 5 +- src/backend/utils/misc/guc_tables.c | 1 - src/include/access/xlogrecovery.h | 2 +- src/include/utils/guc_hooks.h | 3 - src/test/recovery/t/003_recovery_targets.pl | 123 +++++++++++++---- 6 files changed, 145 insertions(+), 129 deletions(-) diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index a9ebac2d0ef..5f3b065b894 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -61,6 +61,7 @@ #include "storage/subsystems.h" #include "utils/datetime.h" #include "utils/fmgrprotos.h" +#include "utils/guc.h" #include "utils/guc_hooks.h" #include "utils/pgstat_internal.h" #include "utils/pg_lsn.h" @@ -92,7 +93,7 @@ int recoveryTargetAction = RECOVERY_TARGET_ACTION_PAUSE; TransactionId recoveryTargetXid; char *recovery_target_time_string; TimestampTz recoveryTargetTime; -const char *recoveryTargetName; +char *recoveryTargetName; XLogRecPtr recoveryTargetLSN; int recovery_min_apply_delay = 0; @@ -392,6 +393,7 @@ static bool HotStandbyActiveInReplay(void); static void SetCurrentChunkStartTime(TimestampTz xtime); static void SetLatestXTime(TimestampTz xtime); +static RecoveryTargetType DetermineRecoveryTargetType(void); /* * Register shared memory for WAL recovery @@ -1067,6 +1069,9 @@ readRecoverySignalFile(void) static void validateRecoveryParameters(void) { + /* Reject conflicting targets even when recovery was not requested */ + recoveryTarget = DetermineRecoveryTargetType(); + if (!ArchiveRecoveryRequested) return; @@ -4769,30 +4774,50 @@ check_primary_slot_name(char **newval, void **extra, GucSource source) } /* - * Recovery target settings: Only one of the several recovery_target* settings - * may be set. Setting a second one results in an error. The global variable - * recoveryTarget tracks which kind of recovery target was chosen. Other - * variables store the actual target value (for example a string or a xid). - * The assign functions of the parameters check whether a competing parameter - * was already set. But we want to allow setting the same parameter multiple - * times. We also want to allow unsetting a parameter and setting a different - * one, so we unset recoveryTarget when the parameter is set to an empty - * string. - * - * XXX this code is broken by design. Throwing an error from a GUC assign - * hook breaks fundamental assumptions of guc.c. So long as all the variables - * for which this can happen are PGC_POSTMASTER, the consequences are limited, - * since we'd just abort postmaster startup anyway. Nonetheless it's likely - * that we have odd behaviors such as unexpected GUC ordering dependencies. + * Return the recovery target derived from the recovery_target* settings, + * raising an error if more than one of them is set. */ - -pg_noreturn static void -error_multiple_recovery_targets(void) +static RecoveryTargetType +DetermineRecoveryTargetType(void) { - ereport(ERROR, - (errcode(ERRCODE_INVALID_PARAMETER_VALUE), - errmsg("multiple recovery targets specified"), - errdetail("At most one of \"recovery_target\", \"recovery_target_lsn\", \"recovery_target_name\", \"recovery_target_time\", \"recovery_target_xid\" may be set."))); + int ntargets = 0; + RecoveryTargetType target = RECOVERY_TARGET_UNSET; + const char *val; + StringInfoData buf; + + initStringInfo(&buf); + +#define ADD_TARGET_IF_SET(gucname, kind) \ + do { \ + val = GetConfigOption(gucname, false, false); \ + if (val[0] != '\0') \ + { \ + ntargets++; \ + target = (kind); \ + if (buf.len == 0) \ + appendStringInfo(&buf, _("\"%s\""), gucname); \ + else \ + appendStringInfo(&buf, _(", \"%s\""), gucname); \ + } \ + } while (0) + + ADD_TARGET_IF_SET("recovery_target", RECOVERY_TARGET_IMMEDIATE); + ADD_TARGET_IF_SET("recovery_target_lsn", RECOVERY_TARGET_LSN); + ADD_TARGET_IF_SET("recovery_target_name", RECOVERY_TARGET_NAME); + ADD_TARGET_IF_SET("recovery_target_time", RECOVERY_TARGET_TIME); + ADD_TARGET_IF_SET("recovery_target_xid", RECOVERY_TARGET_XID); +#undef ADD_TARGET_IF_SET + + if (ntargets > 1) + ereport(FATAL, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("cannot specify more than one recovery target"), + errdetail("Parameters set are: %s.", + buf.data)); + + pfree(buf.data); + + return target; } /* @@ -4809,22 +4834,6 @@ check_recovery_target(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target - */ -void -assign_recovery_target(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_IMMEDIATE) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - recoveryTarget = RECOVERY_TARGET_IMMEDIATE; - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_lsn */ @@ -4856,17 +4865,8 @@ check_recovery_target_lsn(char **newval, void **extra, GucSource source) void assign_recovery_target_lsn(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_LSN) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_LSN; recoveryTargetLSN = *((XLogRecPtr *) extra); - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } /* @@ -4885,25 +4885,6 @@ check_recovery_target_name(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target_name - */ -void -assign_recovery_target_name(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_NAME) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_NAME; - recoveryTargetName = newval; - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_time * @@ -4965,22 +4946,6 @@ check_recovery_target_time(char **newval, void **extra, GucSource source) return true; } -/* - * GUC assign_hook for recovery_target_time - */ -void -assign_recovery_target_time(const char *newval, void *extra) -{ - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_TIME) - error_multiple_recovery_targets(); - - if (newval && strcmp(newval, "") != 0) - recoveryTarget = RECOVERY_TARGET_TIME; - else - recoveryTarget = RECOVERY_TARGET_UNSET; -} - /* * GUC check_hook for recovery_target_timeline */ @@ -5099,15 +5064,6 @@ check_recovery_target_xid(char **newval, void **extra, GucSource source) void assign_recovery_target_xid(const char *newval, void *extra) { - if (recoveryTarget != RECOVERY_TARGET_UNSET && - recoveryTarget != RECOVERY_TARGET_XID) - error_multiple_recovery_targets(); - if (newval && strcmp(newval, "") != 0) - { - recoveryTarget = RECOVERY_TARGET_XID; recoveryTargetXid = *((TransactionId *) extra); - } - else - recoveryTarget = RECOVERY_TARGET_UNSET; } diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index d421cdbde76..adb72361ce0 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2472,7 +2472,6 @@ variable => 'recovery_target_string', boot_val => '""', check_hook => 'check_recovery_target', - assign_hook => 'assign_recovery_target', }, { name => 'recovery_target_action', type => 'enum', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', @@ -2498,10 +2497,9 @@ { name => 'recovery_target_name', type => 'string', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', short_desc => 'Sets the named restore point up to which recovery will proceed.', - variable => 'recovery_target_name_string', + variable => 'recoveryTargetName', boot_val => '""', check_hook => 'check_recovery_target_name', - assign_hook => 'assign_recovery_target_name', }, { name => 'recovery_target_time', type => 'string', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', @@ -2509,7 +2507,6 @@ variable => 'recovery_target_time_string', boot_val => '""', check_hook => 'check_recovery_target_time', - assign_hook => 'assign_recovery_target_time', }, { name => 'recovery_target_timeline', type => 'string', context => 'PGC_POSTMASTER', group => 'WAL_RECOVERY_TARGET', diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c index 90aa374b3ec..1ec460b6a82 100644 --- a/src/backend/utils/misc/guc_tables.c +++ b/src/backend/utils/misc/guc_tables.c @@ -667,7 +667,6 @@ static bool exec_backend_enabled = EXEC_BACKEND_ENABLED; static char *recovery_target_timeline_string; static char *recovery_target_string; static char *recovery_target_xid_string; -static char *recovery_target_name_string; static char *recovery_target_lsn_string; /* should be static, but commands/variable.c needs to get at this */ diff --git a/src/include/access/xlogrecovery.h b/src/include/access/xlogrecovery.h index ba7750dca0b..9ffd44fcbae 100644 --- a/src/include/access/xlogrecovery.h +++ b/src/include/access/xlogrecovery.h @@ -139,7 +139,7 @@ extern PGDLLIMPORT char *archiveCleanupCommand; extern PGDLLIMPORT TransactionId recoveryTargetXid; extern PGDLLIMPORT char *recovery_target_time_string; extern PGDLLIMPORT TimestampTz recoveryTargetTime; -extern PGDLLIMPORT const char *recoveryTargetName; +extern PGDLLIMPORT char *recoveryTargetName; extern PGDLLIMPORT XLogRecPtr recoveryTargetLSN; extern PGDLLIMPORT RecoveryTargetType recoveryTarget; extern PGDLLIMPORT bool wal_receiver_create_temp_slot; diff --git a/src/include/utils/guc_hooks.h b/src/include/utils/guc_hooks.h index 307f4fbaefe..6a76f8d5ed6 100644 --- a/src/include/utils/guc_hooks.h +++ b/src/include/utils/guc_hooks.h @@ -103,16 +103,13 @@ extern bool check_recovery_prefetch(int *new_value, void **extra, extern void assign_recovery_prefetch(int new_value, void *extra); extern bool check_recovery_target(char **newval, void **extra, GucSource source); -extern void assign_recovery_target(const char *newval, void *extra); extern bool check_recovery_target_lsn(char **newval, void **extra, GucSource source); extern void assign_recovery_target_lsn(const char *newval, void *extra); extern bool check_recovery_target_name(char **newval, void **extra, GucSource source); -extern void assign_recovery_target_name(const char *newval, void *extra); extern bool check_recovery_target_time(char **newval, void **extra, GucSource source); -extern void assign_recovery_target_time(const char *newval, void *extra); extern bool check_recovery_target_timeline(char **newval, void **extra, GucSource source); extern void assign_recovery_target_timeline(const char *newval, void *extra); diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 047eb13293a..0469afb5bde 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -12,6 +12,10 @@ use Time::HiRes qw(usleep); # Create and test a standby from given backup, with a certain recovery target. # Choose $until_lsn later than the transaction commit that causes the row # count to reach $num_rows, yet not later than the recovery target. +# If options is given, pass it through Cluster->start(options => ...). This is +# used to exercise scenarios that require the postmaster command line to receive +# multiple "-c name=value" instances of the same GUC, which postgresql.conf +# cannot express because ProcessConfigFile collapses duplicate keys. sub test_recovery_standby { local $Test::Builder::Level = $Test::Builder::Level + 1; @@ -22,6 +26,7 @@ sub test_recovery_standby my $recovery_params = shift; my $num_rows = shift; my $until_lsn = shift; + my %params = @_; my $node_standby = PostgreSQL::Test::Cluster->new($node_name); $node_standby->init_from_backup($node_primary, 'my_backup', @@ -32,7 +37,8 @@ sub test_recovery_standby $node_standby->append_conf('postgresql.conf', qq($param_item)); } - $node_standby->start; + $node_standby->start( + defined $params{options} ? (options => $params{options}) : ()); # Wait until standby has replayed enough data my $caughtup_query = @@ -108,6 +114,9 @@ $node_primary->safe_psql('postgres', # Force archiving of WAL file $node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); +my $lsn6 = + $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); + # Test recovery targets my @recovery_params = ("recovery_target = 'immediate'"); test_recovery_standby('immediate target', @@ -125,11 +134,19 @@ test_recovery_standby('name', 'standby_4', $node_primary, \@recovery_params, test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, "5000", $lsn5); +# Regression: empty-string for one recovery_target_* GUC must not clobber +# another non-empty target. Setting recovery_target_xid + recovery_target_time +# = '' must recover to the xid, not run as no-target recovery. +@recovery_params = + ("recovery_target_xid = '$recovery_txid'", "recovery_target_time = ''"); +test_recovery_standby('xid with empty time GUC', + 'standby_xid_empty_time', $node_primary, \@recovery_params, + "2000", $lsn2); + # Multiple targets # -# Multiple conflicting settings are not allowed, but setting the same -# parameter multiple times or unsetting a parameter and setting a -# different one is allowed. +# Multiple conflicting non-empty settings are rejected, but setting the same +# parameter twice or clearing one with an empty string is allowed. @recovery_params = ( "recovery_target_name = '$recovery_name'", @@ -138,31 +155,9 @@ test_recovery_standby('LSN', 'standby_5', $node_primary, \@recovery_params, test_recovery_standby('multiple overriding settings', 'standby_6', $node_primary, \@recovery_params, "3000", $lsn3); -my $node_standby = PostgreSQL::Test::Cluster->new('standby_7'); -$node_standby->init_from_backup($node_primary, 'my_backup', - has_restoring => 1); -$node_standby->append_conf( - 'postgresql.conf', "recovery_target_name = '$recovery_name' -recovery_target_time = '$recovery_time'"); - -my $res = run_log( - [ - 'pg_ctl', - '--pgdata' => $node_standby->data_dir, - '--log' => $node_standby->logfile, - 'start', - ]); -ok(!$res, 'invalid recovery startup fails'); - -my $logfile = slurp_file($node_standby->logfile()); -like( - $logfile, - qr/multiple recovery targets specified/, - 'multiple conflicting settings'); - # Check behavior when recovery ends before target is reached -$node_standby = PostgreSQL::Test::Cluster->new('standby_8'); +my $node_standby = PostgreSQL::Test::Cluster->new('standby_8'); $node_standby->init_from_backup( $node_primary, 'my_backup', has_restoring => 1, @@ -184,12 +179,84 @@ foreach my $i (0 .. 10 * $PostgreSQL::Test::Utils::timeout_default) last if !-f $node_standby->data_dir . '/postmaster.pid'; usleep(100_000); } -$logfile = slurp_file($node_standby->logfile()); +my $logfile = slurp_file($node_standby->logfile()); like( $logfile, qr/FATAL: .* recovery ended before configured recovery target was reached/, 'recovery end before target reached is a fatal error'); +# Conflicts are rejected at every startup, even without recovery.signal. +# init_from_backup without has_restoring creates no recovery.signal, so this +# cluster would otherwise start as a plain primary; the conflict must still be +# caught. +my $node_no_signal = PostgreSQL::Test::Cluster->new('multi_target_no_signal'); +$node_no_signal->init_from_backup($node_primary, 'my_backup'); +$node_no_signal->append_conf( + 'postgresql.conf', "recovery_target_name = '$recovery_name' +recovery_target_time = '$recovery_time'"); + +ok( !$node_no_signal->start(fail_ok => 1), + 'server fails to start with conflicting recovery targets and no recovery.signal' +); + +my $logfile_no_signal = slurp_file($node_no_signal->logfile()); +like( + $logfile_no_signal, + qr/cannot specify more than one recovery target/, + 'expected error message logged without recovery.signal'); +like( + $logfile_no_signal, + qr/Parameters set are: "recovery_target_name", "recovery_target_time"/, + 'errdetail lists the set parameters in order without recovery.signal'); +unlike( + $logfile_no_signal, + qr/Parameters set are:[^\n]*=/, + 'errdetail does not echo parameter values without recovery.signal'); + +my $node_immediate_conflict = + PostgreSQL::Test::Cluster->new('immediate_target_conflict'); +$node_immediate_conflict->init_from_backup($node_primary, 'my_backup'); +$node_immediate_conflict->append_conf( + 'postgresql.conf', + "recovery_target = 'immediate' +recovery_target_xid = '$recovery_txid'"); + +ok( !$node_immediate_conflict->start(fail_ok => 1), + 'server fails to start with recovery_target=immediate and a second target' +); +like( + slurp_file($node_immediate_conflict->logfile()), + qr/cannot specify more than one recovery target/, + 'recovery_target=immediate conflicting with another target is rejected'); + +# Same-GUC set-then-clear: setting a recovery_target_* GUC and then setting the +# same GUC to an empty string leaves no target, so recovery runs to the end of +# WAL. Duplicate keys collapse in postgresql.conf, so "pg_ctl --options" passes +# both assignments on the postmaster command line. +test_recovery_standby( + 'recovery_target_xid set then cleared', + 'standby_xid_set_clear', + $node_primary, + [], + "6000", + $lsn6, + options => "-c recovery_target_xid=$recovery_txid -c recovery_target_xid=" +); + +# Set recovery_target_xid, then set and clear recovery_target_name. Only the +# xid remains, so recovery must stop at it rather than running to the end of WAL +# (a competing target that is set then cleared must not strand the first one). +test_recovery_standby( + 'recovery target preserved when a competing one is set then cleared', + 'standby_clobber_clear', + $node_primary, + [], + "2000", + $lsn2, + options => + "-c recovery_target_xid=$recovery_txid -c recovery_target_name=$recovery_name -c recovery_target_name=" +); + # Invalid recovery_target_timeline tests my ($result, $stdout, $stderr) = $node_primary->psql('postgres', "ALTER SYSTEM SET recovery_target_timeline TO 'bogus'"); -- 2.55.0
v10-0001-Allow-extra-postmaster-options-in-test-node-star.patch (application/octet-stream, 1.6K) download From fb457cb8628ff0d012b98f38d10eada5fc1823fb Mon Sep 17 00:00:00 2001 From: Fujii Masao <fujii@postgresql.org> Date: Fri, 17 Jul 2026 11:21:52 +0900 Subject: [PATCH v10 1/2] Allow extra postmaster options in test node start --- src/test/perl/PostgreSQL/Test/Cluster.pm | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm index 529f49efee1..3eae4cf6281 100644 --- a/src/test/perl/PostgreSQL/Test/Cluster.pm +++ b/src/test/perl/PostgreSQL/Test/Cluster.pm @@ -1139,6 +1139,12 @@ Start the node and wait until it is ready to accept connections. By default, failure terminates the entire F<prove> invocation. If given, instead return a true or false value to indicate success or failure. +=item options => B<string> + +Additional postmaster options passed as the value of pg_ctl's C<--options> +argument. This must be a single string, quoted as needed by the caller. +Do not specify C<cluster_name>; it is set from the node name. + =back =cut @@ -1150,6 +1156,12 @@ sub start my $pgdata = $self->data_dir; my $name = $self->name; my $ret; + my $options = ""; + + $options .= "$params{options} " + if defined $params{options} && $params{options} ne ""; + + $options .= "--cluster-name=$name"; BAIL_OUT("node \"$name\" is already running") if defined $self->{_pid}; @@ -1168,7 +1180,7 @@ sub start 'pg_ctl', '--wait', '--pgdata' => $self->data_dir, '--log' => $self->logfile, - '--options' => "--cluster-name=$name", + '--options' => $options, 'start'); if ($ret != 0) -- 2.55.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-19 04:38  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Fujii Masao <masao.fujii@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: JoongHyuk Shin @ 2026-07-19 04:38 UTC (permalink / raw)
  To: Fujii Masao <masao.fujii@gmail.com>; +Cc: Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

Hi Fujii,

Thanks for the improvements and the explanation.

v10 looks good to me, no further comments.

-- 
JH Shin

^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-20 12:44  Fujii Masao <masao.fujii@gmail.com>
  parent: JoongHyuk Shin <sjh910805@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Fujii Masao @ 2026-07-20 12:44 UTC (permalink / raw)
  To: JoongHyuk Shin <sjh910805@gmail.com>; +Cc: Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Sun, Jul 19, 2026 at 1:38 PM JoongHyuk Shin <sjh910805@gmail.com> wrote:
>
> Hi Fujii,
>
> Thanks for the improvements and the explanation.
>
> v10 looks good to me, no further comments.

Thanks for the review! I've pushed the patches.

Regards,

-- 
Fujii Masao






^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-20 17:21  Álvaro Herrera <alvherre@kurilemu.de>
  parent: Fujii Masao <masao.fujii@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Álvaro Herrera @ 2026-07-20 17:21 UTC (permalink / raw)
  To: Fujii Masao <masao.fujii@gmail.com>; +Cc: JoongHyuk Shin <sjh910805@gmail.com>; Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

Hi,

On 2026-Jul-20, Fujii Masao wrote:

> Thanks for the review! I've pushed the patches.

Skink is failing this one
https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=skink&dt=2026-07-20%2013%3A07%3A56

-- 
Álvaro Herrera         PostgreSQL Developer  —  https://www.EnterpriseDB.com/






^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-20 18:12  Fujii Masao <masao.fujii@gmail.com>
  parent: Álvaro Herrera <alvherre@kurilemu.de>
  0 siblings, 1 reply; 45+ messages in thread

From: Fujii Masao @ 2026-07-20 18:12 UTC (permalink / raw)
  To: Álvaro Herrera <alvherre@kurilemu.de>; +Cc: JoongHyuk Shin <sjh910805@gmail.com>; Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Tue, Jul 21, 2026 at 2:21 AM Álvaro Herrera <alvherre@kurilemu.de> wrote:
>
> Hi,
>
> On 2026-Jul-20, Fujii Masao wrote:
>
> > Thanks for the review! I've pushed the patches.
>
> Skink is failing this one
> https://buildfarm.postgresql.org/cgi-bin/show_log.pl?nm=skink&dt=2026-07-20%2013%3A07%3A56

Thanks for the report!

In the test added by commit b15039d82d4, the standby restores WAL only
from the archive and waits until it reaches the target LSN. However,
the test records that LSN after pg_switch_wal() in the primary,
so it points into the newly started WAL file. Since pg_switch_wal()
archives only the previous WAL file, the one containing the target LSN
is not yet available in the archive. As a result, the standby waits
indefinitely for that WAL file and eventually times out.

The attached patch fixes this by recording the target LSN before calling
pg_switch_wal(). If CI reports no issues, I'll commit it.

Regards,

-- 
Fujii Masao

Attachments:

v1-0001-Fix-recovery-target-test-waiting-on-unavailable-W.patch (application/octet-stream, 2.1K) download From b15039d82d4687b0714c5eca888bfe2cd3e4707e Mon Sep 17 00:00:00 2001 From: Fujii Masao <fujii@postgresql.org> Date: Tue, 21 Jul 2026 02:39:57 +0900 Subject: [PATCH v1] Fix recovery target test waiting on unavailable WAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buildfarm member skink reported a failure in recovery/003_recovery_targets after commit d5751c33cc3. The newly added recovery_target_xid set-then-cleared test could time out while waiting for pg_last_wal_replay_lsn() to reach the expected LSN. The test recorded lsn6 after calling pg_switch_wal(). As a result, lsn6 pointed into the next WAL segment, but pg_switch_wal() only archived the previous one. Since the standby in this test restores WAL from the archive only, it could not obtain the segment containing lsn6 and waited indefinitely. Fix this by recording lsn6 before calling pg_switch_wal(), so the archived WAL contains the LSN that the standby is waiting for. Per buildfarm member skink. Reported-by: Álvaro Herrera <alvherre@kurilemu.de> Author: Fujii Masao <masao.fujii@gmail.com> Discussion: https://postgr.es/m/al5Y2mWffRs1NP34@alvherre.pgsql --- src/test/recovery/t/003_recovery_targets.pl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/recovery/t/003_recovery_targets.pl b/src/test/recovery/t/003_recovery_targets.pl index 0469afb5bde..db4a0ea74b2 100644 --- a/src/test/recovery/t/003_recovery_targets.pl +++ b/src/test/recovery/t/003_recovery_targets.pl @@ -111,12 +111,12 @@ my $lsn5 = my $recovery_lsn = $node_primary->safe_psql('postgres', "INSERT INTO tab_int VALUES (generate_series(5001,6000))"); -# Force archiving of WAL file -$node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); - my $lsn6 = $node_primary->safe_psql('postgres', "SELECT pg_current_wal_lsn()"); +# Force archiving of WAL file containing $lsn6 +$node_primary->safe_psql('postgres', "SELECT pg_switch_wal()"); + # Test recovery targets my @recovery_params = ("recovery_target = 'immediate'"); test_recovery_standby('immediate target', -- 2.55.0
^ permalink raw reply [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-20 18:28  Fujii Masao <masao.fujii@gmail.com>
  parent: Fujii Masao <masao.fujii@gmail.com>
  0 siblings, 1 reply; 45+ messages in thread

From: Fujii Masao @ 2026-07-20 18:28 UTC (permalink / raw)
  To: Álvaro Herrera <alvherre@kurilemu.de>; +Cc: JoongHyuk Shin <sjh910805@gmail.com>; Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

On Tue, Jul 21, 2026 at 3:12 AM Fujii Masao <masao.fujii@gmail.com> wrote:
> The attached patch fixes this by recording the target LSN before calling
> pg_switch_wal(). If CI reports no issues, I'll commit it.

I've pushed the patch. Thanks!

Regards,

-- 
Fujii Masao






^ permalink  raw  reply  [nested|flat] 45+ messages in thread

* Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks
@ 2026-07-21 02:12  JoongHyuk Shin <sjh910805@gmail.com>
  parent: Fujii Masao <masao.fujii@gmail.com>
  0 siblings, 0 replies; 45+ messages in thread

From: JoongHyuk Shin @ 2026-07-21 02:12 UTC (permalink / raw)
  To: Fujii Masao <masao.fujii@gmail.com>; +Cc: Álvaro Herrera <alvherre@kurilemu.de>; Michael Paquier <michael@paquier.xyz>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org

Thanks for committing this, and for the follow-up fix.

-- 
JH Shin

^ permalink  raw  reply  [nested|flat] 45+ messages in thread


end of thread, other threads:[~2026-07-21 02:12 UTC | newest]

Thread overview: 45+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2025-09-04 14:41 [PATCH v2a 2/4] Introduce ternary reloptions Nikolay Shaplov <dhyan@nataraj.su>
2025-09-04 14:41 [PATCH v2 2/4] Introduce ternary reloptions Nikolay Shaplov <dhyan@nataraj.su>
2026-01-16 15:04 [PATCH v3] Introduce ternary reloptions Álvaro Herrera <alvherre@kurilemu.de>
2026-04-13 08:21 [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-04-24 01:10 ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Greg Lamberson <greg@lamco.io>
2026-04-24 13:08 ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Fujii Masao <masao.fujii@gmail.com>
2026-04-27 01:52   ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-04-27 05:36     ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Fujii Masao <masao.fujii@gmail.com>
2026-04-27 05:59       ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-04-29 09:29         ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-05-01 05:53           ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Fujii Masao <masao.fujii@gmail.com>
2026-05-11 03:17             ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-05-11 06:01               ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-05-31 21:11                 ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Scott Ray <scott@scottray.io>
2026-06-04 05:41                   ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-06-06 20:10                     ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Scott Ray <scott@scottray.io>
2026-06-07 10:30                       ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-06-07 15:44                         ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Álvaro Herrera <alvherre@kurilemu.de>
2026-06-21 09:27                           ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-06-21 12:31                             ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Álvaro Herrera <alvherre@kurilemu.de>
2026-06-22 04:37                               ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-06-25 08:00                                 ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-06-26 07:15                                   ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Kyotaro Horiguchi <horikyota.ntt@gmail.com>
2026-06-26 08:12                                     ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-06-26 11:12                                 ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Henson Choi <assam258@gmail.com>
2026-06-29 04:45                                   ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-06-29 05:47                                     ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-06-29 07:59                                       ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-06-29 21:16                                       ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Zsolt Parragi <zsolt.parragi@percona.com>
2026-06-29 23:35                                         ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-07-06 07:53                                           ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-07-06 22:30                                             ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-07-07 03:01                                               ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-07-07 06:58                                                 ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-07-07 06:59                                                 ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-07-08 01:38                                                   ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Fujii Masao <masao.fujii@gmail.com>
2026-07-08 01:43                                                     ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Michael Paquier <michael@paquier.xyz>
2026-07-12 10:45                                                       ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-07-17 09:30                                                         ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Fujii Masao <masao.fujii@gmail.com>
2026-07-19 04:38                                                           ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>
2026-07-20 12:44                                                             ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Fujii Masao <masao.fujii@gmail.com>
2026-07-20 17:21                                                               ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Álvaro Herrera <alvherre@kurilemu.de>
2026-07-20 18:12                                                                 ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Fujii Masao <masao.fujii@gmail.com>
2026-07-20 18:28                                                                   ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks Fujii Masao <masao.fujii@gmail.com>
2026-07-21 02:12                                                                     ` Re: [PATCH] Don't call ereport(ERROR) from recovery target GUC assign hooks JoongHyuk Shin <sjh910805@gmail.com>

This inbox is served by DDX for PostgreSQL; see mirroring instructions
for how to clone and mirror all data and code used for this inbox