Monthly Summary: Moving recovery target conflict detection out of GUC assign hooks (May 2026)
Problem Statement
PostgreSQL's five recovery_target_* GUCs (recovery_target, recovery_target_lsn, recovery_target_name, recovery_target_time, recovery_target_xid) violate the GUC subsystem's contract that assign hooks must not fail. Each assign hook calls ereport(ERROR) to enforce mutual exclusion (only one recovery target may be set at a time). This was introduced by commit f2cbffc7a618 and has been flagged with an XXX comment for years. It's tolerated only because these are PGC_POSTMASTER GUCs — failures abort startup before corrupted guc.c state is observable.
The fundamental issue: GUC hooks operate on a single variable at a time and are not the right place for cross-variable invariants. The correct location is a post-load validation pass, which validateRecoveryParameters() in xlogrecovery.c already provides.
Patch Evolution
v1: Naive refactoring
Moved the conflict check to validateRecoveryParameters() using GetConfigOption() to count non-empty targets. Two regressions identified:
- Deferred error detection (Greg): The check ran after an
ArchiveRecoveryRequestedearly-return, meaning misconfigurations wouldn't surface until recovery was actually attempted rather than at startup. - Empty-string clobber (Fujii): Removing conflict checks exposed a latent bug where an empty assignment to one GUC (e.g.,
recovery_target_time='') would resetrecoveryTargettoUNSET, silently wiping a previously-set valid target from a different GUC.
v2: Addressing both regressions
- Factored conflict check into
CheckRecoveryTargetConflicts(), called before theArchiveRecoveryRequestedearly return — restoring early detection at every startup. - Removed the
else recoveryTarget = UNSETbranch from assign hooks entirely. - Fujii identified a remaining edge case: same-GUC clearing (
-c "recovery_target_xid=700" -c "recovery_target_xid=") no longer works because the else branch is gone.
v3: Guarded reset (key design breakthrough)
Introduced a conditional else branch in each assign hook:
else if (recoveryTarget == MY_TYPE)
recoveryTarget = RECOVERY_TARGET_UNSET;
Each hook only resets recoveryTarget if its own target type is currently active. This elegantly handles both cases:
- Same-GUC clear:
xid=700thenxid=''→ xid hook seesrecoveryTarget == RECOVERY_TARGET_XID, resets to UNSET. ✓ - Cross-GUC empty:
xid=700thentime=''→ time hook seesrecoveryTarget == RECOVERY_TARGET_XID(not TIME), does nothing. ✓
Added seven new TAP test cases in 003_recovery_targets.pl covering all combinations, using CLI path (since postgresql.conf deduplicates keys).
v4: Windows portability fix
CFBot caught POSIX single-quote issues on Windows. Fixed by dropping single quotes around GUC values in --options arguments and switching to ISO 8601 timestamps with T separator (no spaces, no quoting needed). No functional change.
Design Principles Established
- GUC hooks should be "dumb": store, parse, range-check only.
- Cross-GUC validation belongs in a post-load validation pass that reads final GUC values.
- HEAD-only fix, no backpatch: The original violation has shipped for years without user-visible issues since these are PGC_POSTMASTER GUCs.
Current Status
v4 is the latest patch version, addressing all identified functional issues and test portability. The approach is architecturally sound and has consensus among reviewers.