diff --git a/contrib/Makefile b/contrib/Makefile index 5ff4278c4d3..a8bc2d356ba 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -36,6 +36,7 @@ SUBDIRS = \ pg_prewarm \ pg_stat_statements \ pg_surgery \ + pg_target_promote \ pg_trgm \ pgrowlocks \ pgstattuple \ diff --git a/contrib/meson.build b/contrib/meson.build index ff4af9c13e8..e0fc76cc01a 100644 --- a/contrib/meson.build +++ b/contrib/meson.build @@ -53,6 +53,7 @@ subdir('pgrowlocks') subdir('pg_stat_statements') subdir('pgstattuple') subdir('pg_surgery') +subdir('pg_target_promote') subdir('pg_trgm') subdir('pg_visibility') subdir('pg_walinspect') diff --git a/contrib/pg_target_promote/Makefile b/contrib/pg_target_promote/Makefile new file mode 100644 index 00000000000..05dd5e5a55f --- /dev/null +++ b/contrib/pg_target_promote/Makefile @@ -0,0 +1,21 @@ +# contrib/pg_target_promote/Makefile + +MODULE_big = pg_target_promote +OBJS = \ + $(WIN32RES) \ + pg_target_promote.o + +EXTENSION = pg_target_promote +DATA = pg_target_promote--1.0.sql +PGFILEDESC = "pg_target_promote - promote standby to a target timeline" + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = contrib/pg_target_promote +top_builddir = ../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/contrib/pg_target_promote/meson.build b/contrib/pg_target_promote/meson.build new file mode 100644 index 00000000000..d63857677c5 --- /dev/null +++ b/contrib/pg_target_promote/meson.build @@ -0,0 +1,23 @@ +# Copyright (c) 2025-2025, PostgreSQL Global Development Group + +pg_target_promote_sources = files( + 'pg_target_promote.c', +) + +if host_system == 'windows' + pg_target_promote_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'pg_target_promote', + '--FILEDESC', 'pg_target_promote - promote standby to a target timeline',]) +endif + +pg_target_promote = shared_module('pg_target_promote', + pg_target_promote_sources, + kwargs: contrib_mod_args, +) +contrib_targets += pg_target_promote + +install_data( + 'pg_target_promote--1.0.sql', + 'pg_target_promote.control', + kwargs: contrib_data_args, +) diff --git a/contrib/pg_target_promote/pg_target_promote--1.0.sql b/contrib/pg_target_promote/pg_target_promote--1.0.sql new file mode 100644 index 00000000000..5f662fb7353 --- /dev/null +++ b/contrib/pg_target_promote/pg_target_promote--1.0.sql @@ -0,0 +1,13 @@ +/* contrib/pg_target_promote/pg_target_promote--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION pg_target_promote" to load this file. \quit + +CREATE FUNCTION pg_target_promote(target_timeline integer, + wait boolean DEFAULT true, + wait_seconds integer DEFAULT 60) +RETURNS boolean +AS 'MODULE_PATHNAME', 'pg_target_promote_proxy' +LANGUAGE C STRICT VOLATILE PARALLEL SAFE; + +REVOKE EXECUTE ON FUNCTION pg_target_promote(integer, boolean, integer) FROM public; diff --git a/contrib/pg_target_promote/pg_target_promote.c b/contrib/pg_target_promote/pg_target_promote.c new file mode 100644 index 00000000000..e9a58341404 --- /dev/null +++ b/contrib/pg_target_promote/pg_target_promote.c @@ -0,0 +1,26 @@ +/* contrib/pg_target_promote/pg_target_promote.c */ + +#include "postgres.h" + +#include "access/xlog.h" +#include "fmgr.h" + +/* + * pg_target_promote() is implemented in the core backend + * (src/backend/access/transam/xlogfuncs.c). This extension provides + * a thin proxy so that the SQL function is only available after + * CREATE EXTENSION, without modifying the core pg_proc catalog. + */ + +PG_MODULE_MAGIC; + +PG_FUNCTION_INFO_V1(pg_target_promote_proxy); + +Datum +pg_target_promote_proxy(PG_FUNCTION_ARGS) +{ + return DirectFunctionCall3(pg_target_promote, + PG_GETARG_DATUM(0), + PG_GETARG_DATUM(1), + PG_GETARG_DATUM(2)); +} diff --git a/contrib/pg_target_promote/pg_target_promote.control b/contrib/pg_target_promote/pg_target_promote.control new file mode 100644 index 00000000000..614fd731782 --- /dev/null +++ b/contrib/pg_target_promote/pg_target_promote.control @@ -0,0 +1,5 @@ +# contrib/pg_target_promote/pg_target_promote.control +comment = 'promote standby server to a target timeline' +default_version = '1.0' +module_pathname = '$libdir/pg_target_promote' +relocatable = true diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index bc774dd151e..9c28ce3be38 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -5989,7 +5989,27 @@ StartupXLOG(void) newTLI = endOfRecoveryInfo->lastRecTLI; if (ArchiveRecoveryRequested) { - newTLI = findNewestTimeLine(recoveryTargetTLI) + 1; + /* + * If pg_target_promote() requested a specific timeline, use it. + * Otherwise, pick the next available timeline ID automatically. + */ + if (promoteTargetTLI != 0) + { + newTLI = promoteTargetTLI; + + /* + * The requested timeline must not already exist; otherwise we + * would risk conflicting with existing WAL on that timeline. + */ + if (existsTimeLineHistory(newTLI)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("target timeline %u already exists", + newTLI))); + } + else + newTLI = findNewestTimeLine(recoveryTargetTLI) + 1; + ereport(LOG, (errmsg("selected new timeline ID: %u", newTLI))); diff --git a/src/backend/access/transam/xlogfuncs.c b/src/backend/access/transam/xlogfuncs.c index 8c3090165f0..e91552252ad 100644 --- a/src/backend/access/transam/xlogfuncs.c +++ b/src/backend/access/transam/xlogfuncs.c @@ -19,6 +19,7 @@ #include #include "access/htup_details.h" +#include "access/timeline.h" #include "access/xlog_internal.h" #include "access/xlogbackup.h" #include "access/xlogrecovery.h" @@ -748,3 +749,158 @@ pg_promote(PG_FUNCTION_ARGS) wait_seconds))); PG_RETURN_BOOL(false); } + +/* + * Promotes a standby server, switching to a specific target timeline. + * + * This is like pg_promote(), but the caller specifies the timeline ID to + * switch to, rather than letting the server choose the next available one. + * A result of "true" means that promotion has been completed if "wait" is + * "true", or initiated if "wait" is false. + */ +Datum +pg_target_promote(PG_FUNCTION_ARGS) +{ + TimeLineID target_tli = PG_GETARG_INT32(0); + bool wait = PG_GETARG_BOOL(1); + int wait_seconds = PG_GETARG_INT32(2); + FILE *promote_file; + FILE *target_file; + char tli_buf[32]; + int tli_len; + int i; + + if (!RecoveryInProgress()) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("recovery is not in progress"), + errhint("Recovery control functions can only be executed during recovery."))); + + if (wait_seconds <= 0) + ereport(ERROR, + (errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE), + errmsg("\"wait_seconds\" must not be negative or zero"))); + + if (target_tli == 0) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("\"target_timeline\" must be greater than 0"))); + + { + TimeLineID current_tli; + + GetXLogReplayRecPtr(¤t_tli); + + if (target_tli < current_tli) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("target timeline %u must be greater than or equal to current timeline %u", + target_tli, current_tli))); + } + + /* + * The requested timeline must not already exist; otherwise we would risk + * conflicting with existing WAL on that timeline. + */ + if (existsTimeLineHistory(target_tli)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("timeline %u already exists", target_tli))); + + /* create the promote signal file */ + promote_file = AllocateFile(PROMOTE_SIGNAL_FILE, "w"); + if (!promote_file) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", + PROMOTE_SIGNAL_FILE))); + + if (FreeFile(promote_file)) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write file \"%s\": %m", + PROMOTE_SIGNAL_FILE))); + + /* create the target timeline signal file */ + target_file = AllocateFile(PROMOTE_TARGET_SIGNAL_FILE, "w"); + if (!target_file) + { + (void) unlink(PROMOTE_SIGNAL_FILE); + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", + PROMOTE_TARGET_SIGNAL_FILE))); + } + + tli_len = snprintf(tli_buf, sizeof(tli_buf), "%u\n", target_tli); + + if (fwrite(tli_buf, 1, tli_len, target_file) != (size_t) tli_len) + { + (void) FreeFile(target_file); + (void) unlink(PROMOTE_SIGNAL_FILE); + (void) unlink(PROMOTE_TARGET_SIGNAL_FILE); + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write file \"%s\": %m", + PROMOTE_TARGET_SIGNAL_FILE))); + } + + if (FreeFile(target_file)) + { + (void) unlink(PROMOTE_SIGNAL_FILE); + (void) unlink(PROMOTE_TARGET_SIGNAL_FILE); + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write file \"%s\": %m", + PROMOTE_TARGET_SIGNAL_FILE))); + } + + /* signal the postmaster */ + if (kill(PostmasterPid, SIGUSR1) != 0) + { + (void) unlink(PROMOTE_SIGNAL_FILE); + (void) unlink(PROMOTE_TARGET_SIGNAL_FILE); + ereport(ERROR, + (errcode(ERRCODE_SYSTEM_ERROR), + errmsg("failed to send signal to postmaster: %m"))); + } + + /* return immediately if waiting was not requested */ + if (!wait) + PG_RETURN_BOOL(true); + + /* wait for the amount of time wanted until promotion */ + for (i = 0; i < WAITS_PER_SECOND * wait_seconds; i++) + { + int rc; + + ResetLatch(MyLatch); + + if (!RecoveryInProgress()) + PG_RETURN_BOOL(true); + + CHECK_FOR_INTERRUPTS(); + + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH, + 1000L / WAITS_PER_SECOND, + WAIT_EVENT_PROMOTE); + + /* + * Emergency bailout if postmaster has died. This is to avoid the + * necessity for manual cleanup of all postmaster children. + */ + if (rc & WL_POSTMASTER_DEATH) + ereport(FATAL, + (errcode(ERRCODE_ADMIN_SHUTDOWN), + errmsg("terminating connection due to unexpected postmaster exit"), + errcontext("while waiting on promotion"))); + } + + ereport(WARNING, + (errmsg_plural("server did not promote within %d second", + "server did not promote within %d seconds", + wait_seconds, + wait_seconds))); + PG_RETURN_BOOL(false); +} diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 2497285294b..ec42201e16a 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -125,6 +125,12 @@ TimeLineID recoveryTargetTLI = 0; static List *expectedTLEs; static TimeLineID curFileTLI; +/* + * promoteTargetTLI: target timeline requested by pg_target_promote(), or 0 + * if no specific timeline was requested (i.e. regular pg_promote()). + */ +TimeLineID promoteTargetTLI = 0; + /* * When ArchiveRecoveryRequested is set, archive recovery was requested, * ie. signal files were present. When InArchiveRecovery is set, we are @@ -442,6 +448,7 @@ static int XLogFileReadAnyTLI(XLogSegNo segno, XLogSource source); static bool CheckForStandbyTrigger(void); static void SetPromoteIsTriggered(void); +static TimeLineID ReadPromoteTargetTLI(void); static bool HotStandbyActiveInReplay(void); static void SetCurrentChunkStartTime(TimestampTz xtime); @@ -3434,11 +3441,13 @@ XLogPageRead(XLogReaderState *xlogreader, XLogRecPtr targetPagePtr, int reqLen, pgstat_report_wait_start(WAIT_EVENT_WAL_READ); #if defined(USE_POSIX_FADVISE) && defined(POSIX_FADV_WILLNEED) + /* - * Prefetch next wal blocks to avoid page misses on next read iterations. + * Prefetch next wal blocks to avoid page misses on next read iterations. */ #define RACHUNK (16*1024*1024) - if (readOff == 0) { + if (readOff == 0) + { posix_fadvise(readFile, 0, RACHUNK, POSIX_FADV_WILLNEED); } #endif @@ -4501,6 +4510,15 @@ CheckForStandbyTrigger(void) if (IsPromoteSignaled() && CheckPromoteSignal()) { ereport(LOG, (errmsg("received promote request"))); + + /* + * If a target timeline file was created by pg_target_promote(), read + * the requested timeline ID. If the file cannot be read or contains + * an invalid value, fall back to regular promotion behaviour (let the + * server choose the next timeline). + */ + promoteTargetTLI = ReadPromoteTargetTLI(); + RemovePromoteSignalFiles(); ResetPromoteSignaled(); SetPromoteIsTriggered(); @@ -4510,6 +4528,65 @@ CheckForStandbyTrigger(void) return false; } +/* + * Read the target timeline ID from the PROMOTE_TARGET_SIGNAL_FILE. + * Returns 0 if the file does not exist or is invalid, meaning that + * no specific target timeline was requested. + */ +static TimeLineID +ReadPromoteTargetTLI(void) +{ + struct stat stat_buf; + FILE *file; + char buf[64]; + size_t nread; + unsigned long val; + char *endptr; + + if (stat(PROMOTE_TARGET_SIGNAL_FILE, &stat_buf) != 0) + return 0; + + file = AllocateFile(PROMOTE_TARGET_SIGNAL_FILE, PG_BINARY_R); + if (!file) + { + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not read file \"%s\": %m", + PROMOTE_TARGET_SIGNAL_FILE))); + return 0; + } + + nread = fread(buf, 1, sizeof(buf) - 1, file); + buf[nread] = '\0'; + + FreeFile(file); + + /* remove the file so it's not accidentally re-read on next promotion */ + unlink(PROMOTE_TARGET_SIGNAL_FILE); + + errno = 0; + val = strtoul(buf, &endptr, 10); + if (errno != 0 || endptr == buf || *endptr != '\0' && + *endptr != '\n' && *endptr != '\r') + { + ereport(WARNING, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("invalid target timeline in file \"%s\"", + PROMOTE_TARGET_SIGNAL_FILE))); + return 0; + } + + if (val == 0) + { + ereport(WARNING, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("target timeline must be greater than 0"))); + return 0; + } + + return (TimeLineID) val; +} + /* * Remove the files signaling a standby promotion request. */ @@ -4517,6 +4594,7 @@ void RemovePromoteSignalFiles(void) { unlink(PROMOTE_SIGNAL_FILE); + unlink(PROMOTE_TARGET_SIGNAL_FILE); } /* diff --git a/src/bin/pg_ctl/pg_ctl.c b/src/bin/pg_ctl/pg_ctl.c index 8a405ff122c..d866a826300 100644 --- a/src/bin/pg_ctl/pg_ctl.c +++ b/src/bin/pg_ctl/pg_ctl.c @@ -93,11 +93,13 @@ static char *register_password = NULL; static char *argv0 = NULL; static bool allow_core_files = false; static time_t start_time; +static int promote_target_tli = 0; static char postopts_file[MAXPGPATH]; static char version_file[MAXPGPATH]; static char pid_file[MAXPGPATH]; static char promote_file[MAXPGPATH]; +static char promote_target_file[MAXPGPATH]; static char logrotate_file[MAXPGPATH]; static volatile pid_t postmasterPID = -1; @@ -1186,6 +1188,7 @@ static void do_promote(void) { FILE *prmfile; + FILE *targetfile = NULL; pid_t pid; pid = get_pgpid(false); @@ -1228,6 +1231,35 @@ do_promote(void) exit(1); } + if (promote_target_tli > 0) + { + snprintf(promote_target_file, MAXPGPATH, "%s/promote_target_tli", pg_data); + + if ((targetfile = fopen(promote_target_file, "w")) == NULL) + { + write_stderr(_("%s: could not create target timeline file \"%s\": %m\n"), + progname, promote_target_file); + if (unlink(promote_file) != 0) + write_stderr(_("%s: could not remove promote signal file \"%s\": %m\n"), + progname, promote_file); + exit(1); + } + + if (fprintf(targetfile, "%u\n", promote_target_tli) < 0 || + fclose(targetfile) != 0) + { + write_stderr(_("%s: could not write target timeline file \"%s\": %m\n"), + progname, promote_target_file); + if (unlink(promote_file) != 0) + write_stderr(_("%s: could not remove promote signal file \"%s\": %m\n"), + progname, promote_file); + if (unlink(promote_target_file) != 0) + write_stderr(_("%s: could not remove target timeline file \"%s\": %m\n"), + progname, promote_target_file); + exit(1); + } + } + sig = SIGUSR1; if (kill(pid, sig) != 0) { @@ -1236,6 +1268,9 @@ do_promote(void) if (unlink(promote_file) != 0) write_stderr(_("%s: could not remove promote signal file \"%s\": %m\n"), progname, promote_file); + if (promote_target_tli > 0 && unlink(promote_target_file) != 0) + write_stderr(_("%s: could not remove target timeline file \"%s\": %m\n"), + progname, promote_target_file); exit(1); } @@ -1982,7 +2017,7 @@ do_help(void) " [-o OPTIONS] [-c]\n"), progname); printf(_(" %s reload [-D DATADIR] [-s]\n"), progname); printf(_(" %s status [-D DATADIR]\n"), progname); - printf(_(" %s promote [-D DATADIR] [-W] [-t SECS] [-s]\n"), progname); + printf(_(" %s promote [-D DATADIR] [-W] [-t SECS] [-s] [-T TARGET-TLI]\n"), progname); printf(_(" %s logrotate [-D DATADIR] [-s]\n"), progname); printf(_(" %s kill SIGNALNAME PID\n"), progname); #ifdef WIN32 @@ -2022,6 +2057,9 @@ do_help(void) printf(_(" fast quit directly, with proper shutdown (default)\n")); printf(_(" immediate quit without complete shutdown; will lead to recovery on restart\n")); + printf(_("\nOptions for promote:\n")); + printf(_(" -T, --target=TLI target timeline ID to switch to upon promotion\n")); + printf(_("\nAllowed signal names for kill:\n")); printf(" ABRT HUP INT KILL QUIT TERM USR1 USR2\n"); @@ -2213,6 +2251,7 @@ main(int argc, char **argv) {"core-files", no_argument, NULL, 'c'}, {"wait", no_argument, NULL, 'w'}, {"no-wait", no_argument, NULL, 'W'}, + {"target", required_argument, NULL, 'T'}, {NULL, 0, NULL, 0} }; @@ -2270,7 +2309,7 @@ main(int argc, char **argv) wait_seconds = atoi(env_wait); /* process command-line options */ - while ((c = getopt_long(argc, argv, "cD:e:l:m:N:o:p:P:sS:t:U:wW", + while ((c = getopt_long(argc, argv, "cD:e:l:m:N:o:p:P:sS:t:T:U:wW", long_options, &option_index)) != -1) { switch (c) @@ -2353,6 +2392,16 @@ main(int argc, char **argv) case 'c': allow_core_files = true; break; + case 'T': + promote_target_tli = atoi(optarg); + if (promote_target_tli <= 0) + { + write_stderr(_("%s: invalid target timeline \"%s\"\n"), + progname, optarg); + do_advice(); + exit(1); + } + break; default: /* getopt_long already issued a suitable error message */ do_advice(); @@ -2423,6 +2472,14 @@ main(int argc, char **argv) exit(1); } + if (promote_target_tli > 0 && ctl_command != PROMOTE_COMMAND) + { + write_stderr(_("%s: --target option can only be used with promote command\n"), + progname); + do_advice(); + exit(1); + } + /* Note we put any -D switch into the env var above */ pg_config = getenv("PGDATA"); if (pg_config) diff --git a/src/include/access/xlog.h b/src/include/access/xlog.h index ccbf0b103c8..9a643d45544 100644 --- a/src/include/access/xlog.h +++ b/src/include/access/xlog.h @@ -14,6 +14,7 @@ #include "access/xlogbackup.h" #include "access/xlogdefs.h" #include "datatype/timestamp.h" +#include "fmgr.h" #include "lib/stringinfo.h" #include "nodes/pg_list.h" @@ -333,5 +334,9 @@ extern SessionBackupState get_backup_status(void); /* files to signal promotion to primary */ #define PROMOTE_SIGNAL_FILE "promote" +#define PROMOTE_TARGET_SIGNAL_FILE "promote_target_tli" + +/* promoted by extension, symbol exported for contrib/pg_target_promote */ +extern PGDLLEXPORT Datum pg_target_promote(PG_FUNCTION_ARGS); #endif /* XLOG_H */ diff --git a/src/include/access/xlogrecovery.h b/src/include/access/xlogrecovery.h index 91446303024..4d1f6b004e1 100644 --- a/src/include/access/xlogrecovery.h +++ b/src/include/access/xlogrecovery.h @@ -69,6 +69,7 @@ extern PGDLLIMPORT bool wal_receiver_create_temp_slot; extern PGDLLIMPORT RecoveryTargetTimeLineGoal recoveryTargetTimeLineGoal; extern PGDLLIMPORT TimeLineID recoveryTargetTLIRequested; extern PGDLLIMPORT TimeLineID recoveryTargetTLI; +extern PGDLLIMPORT TimeLineID promoteTargetTLI; /* Have we already reached a consistent database state? */ extern PGDLLIMPORT bool reachedConsistency; diff --git a/src/test/recovery/.gitignore b/src/test/recovery/.gitignore index 871e943d50e..0ab23229e6c 100644 --- a/src/test/recovery/.gitignore +++ b/src/test/recovery/.gitignore @@ -1,2 +1,3 @@ # Generated by test suite /tmp_check/ +/log/ diff --git a/src/test/recovery/t/113_target_promote.pl b/src/test/recovery/t/113_target_promote.pl new file mode 100644 index 00000000000..010b0837529 --- /dev/null +++ b/src/test/recovery/t/113_target_promote.pl @@ -0,0 +1,186 @@ + +# Copyright (c) 2025, PostgreSQL Global Development Group + +# Test for pg_target_promote(), which is like pg_promote() but allows +# specifying the target timeline ID to switch to upon promotion. +use strict; +use warnings FATAL => 'all'; +use File::Copy qw(copy); +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# Helper: get the current timeline of a node by looking at the WAL +# segment filename. Works both during recovery and after promotion. +sub get_timeline_from_wal +{ + my ($node) = @_; + my $wal_dir = $node->data_dir . '/pg_wal'; + opendir(my $dh, $wal_dir) or die "cannot open $wal_dir: $!"; + my @files = grep { /^\d{24}$/ } readdir($dh); + closedir($dh); + # Sort descending to get the newest segment + @files = sort { $b cmp $a } @files; + return substr($files[0], 0, 8) if @files; + return undef; +} + +# Initialize primary node +my $node_primary = PostgreSQL::Test::Cluster->new('primary'); +$node_primary->init(allows_streaming => 1); +$node_primary->start; + +# Take backup +my $backup_name = 'my_backup'; +$node_primary->backup($backup_name); + +# Create standby linking to primary +my $node_standby = PostgreSQL::Test::Cluster->new('standby'); +$node_standby->init_from_backup($node_primary, $backup_name, + has_streaming => 1); +$node_standby->start; + +# Load the extension that provides pg_target_promote() +$node_standby->safe_psql('postgres', + "CREATE EXTENSION pg_target_promote"); + +# Wait for standby to connect to primary +$node_primary->poll_query_until('postgres', + "SELECT count(1) = 1 FROM pg_stat_replication"); + +# Create some content on primary and wait for standby to catch up +$node_primary->safe_psql('postgres', + "CREATE TABLE tab_int AS SELECT generate_series(1,1000) AS a"); +$node_primary->wait_for_catchup($node_standby); + +# Stop primary cleanly so standby has all WAL +$node_primary->stop; + +# Verify the standby is on timeline 1 +my $standby_tli = get_timeline_from_wal($node_standby); +is($standby_tli, '00000001', "standby initially on timeline 1"); + +# Promote the standby to timeline 5 using pg_target_promote() +my $psql_out = ''; +$node_standby->psql( + 'postgres', + "SELECT pg_target_promote(5, true, 300)", + stdout => \$psql_out); +is($psql_out, 't', "pg_target_promote returns true"); + +# Wait for promotion to complete +$node_standby->poll_query_until('postgres', + "SELECT NOT pg_is_in_recovery()") + or die "Timed out while waiting for promotion"; + +# Verify that the standby is no longer in recovery +is($node_standby->safe_psql('postgres', "SELECT pg_is_in_recovery()"), + 'f', "standby promoted out of recovery"); + +# Verify the timeline is now 5 by checking the WAL filename +$node_standby->safe_psql('postgres', "SELECT pg_switch_wal()"); +my $walfile = $node_standby->safe_psql('postgres', + "SELECT pg_walfile_name(pg_current_wal_lsn())"); +my $promoted_tli = substr($walfile, 0, 8); +is($promoted_tli, '00000005', "promoted standby is on timeline 5"); + +# Verify we can write to the promoted primary +$node_standby->safe_psql('postgres', + "INSERT INTO tab_int VALUES (generate_series(1001,2000))"); +my $count = $node_standby->safe_psql('postgres', + "SELECT count(*) FROM tab_int"); +is($count, '2000', "data present on promoted standby"); + +# Verify the timeline history file was created +my $pg_wal = $node_standby->data_dir . "/pg_wal"; +ok(-f "$pg_wal/00000005.history", "timeline history file for TLI 5 exists"); + +# --- +# Test: pg_target_promote fails when not in recovery +# --- +my $ret = $node_standby->psql('postgres', + "SELECT pg_target_promote(6, true, 60)", + stdout => \$psql_out); +isnt($ret, 0, "pg_target_promote fails when not in recovery"); + +# Stop the promoted standby, so we can set up a fresh standby +$node_standby->stop; + +# --- +# Test: pg_target_promote with an already-existing timeline should fail +# --- +# Set up a fresh standby from the original primary backup +my $node_standby2 = PostgreSQL::Test::Cluster->new('standby2'); +$node_standby2->init_from_backup($node_primary, $backup_name, + has_streaming => 1); +$node_standby2->start; + +# Load the extension on standby2 as well +$node_standby2->safe_psql('postgres', + "CREATE EXTENSION pg_target_promote"); + +# Wait for it to be in recovery +ok($node_standby2->safe_psql('postgres', "SELECT pg_is_in_recovery()"), + "standby2 is in recovery"); + +# Copy the history file from the promoted standby to standby2's pg_wal +# so that existsTimeLineHistory() will find timeline 5 as existing. +my $standby_pg_wal = $node_standby->data_dir . "/pg_wal"; +my $standby2_pg_wal = $node_standby2->data_dir . "/pg_wal"; +copy("$standby_pg_wal/00000005.history", "$standby2_pg_wal/00000005.history") + or die "copy failed: $!"; + +# Now try to promote standby2 to timeline 5, which already exists +$ret = $node_standby2->psql('postgres', + "SELECT pg_target_promote(5, true, 60)", + stdout => \$psql_out); +isnt($ret, 0, "pg_target_promote fails with already-existing timeline"); + +# Verify standby2 is still in recovery +ok($node_standby2->safe_psql('postgres', "SELECT pg_is_in_recovery()"), + "standby2 still in recovery after failed promotion"); + +# --- +# Test: pg_target_promote with target_timeline = 0 should fail +# --- +$ret = $node_standby2->psql('postgres', + "SELECT pg_target_promote(0, true, 60)", + stdout => \$psql_out); +isnt($ret, 0, "pg_target_promote fails with timeline 0"); + +# Verify standby2 is still in recovery +ok($node_standby2->safe_psql('postgres', "SELECT pg_is_in_recovery()"), + "standby2 still in recovery after invalid timeline"); + +# Remove the history file so timeline 5 no longer appears to exist +# for standby2, then promote it to a valid unused timeline (6) +unlink("$standby2_pg_wal/00000005.history"); + +# --- +# Test: promote standby2 to a different valid timeline (6) +# --- +$ret = $node_standby2->psql('postgres', + "SELECT pg_target_promote(6, true, 300)", + stdout => \$psql_out); +is($psql_out, 't', "pg_target_promote succeeds with timeline 6"); + +# Wait for promotion to complete +$node_standby2->poll_query_until('postgres', + "SELECT NOT pg_is_in_recovery()") + or die "Timed out while waiting for promotion of standby2"; + +# Verify that the standby is no longer in recovery +is($node_standby2->safe_psql('postgres', "SELECT pg_is_in_recovery()"), + 'f', "standby2 promoted out of recovery"); + +# Verify the timeline is now 6 +$node_standby2->safe_psql('postgres', "SELECT pg_switch_wal()"); +$walfile = $node_standby2->safe_psql('postgres', + "SELECT pg_walfile_name(pg_current_wal_lsn())"); +my $promoted2_tli = substr($walfile, 0, 8); +is($promoted2_tli, '00000006', "standby2 promoted to timeline 6"); + +# Clean up +$node_standby2->stop; + +done_testing();