From 3fad101867d7b7577a51f4404a73dec242f1ce12 Mon Sep 17 00:00:00 2001 From: Andrey Borodin Date: Mon, 19 Oct 2026 17:32:57 +0500 Subject: [PATCH v1] Allow an externally allocated timeline ID in pg_promote Add an overload of pg_promote that accepts a timeline ID allocated by an external coordinator. Validate it against local recovery state and files, without probing the archive to allocate an ID or refetching the parent history at promotion. The caller remains responsible for global uniqueness and fencing. Keep automatic allocation for existing callers. Pass SQL promotion requests through shared memory, with a fixed choice once a request is accepted. Follow the ancestor history rather than an integer interval when fetching history files, and discover local history files across gaps. Archive-only discovery across gaps still requires an explicit recovery target. Parse streaming timeline IDs as unsigned values and add recovery tests for these contracts. A catversion bump is required when committing. --- doc/src/sgml/func/func-admin.sgml | 57 ++++- src/backend/access/transam/timeline.c | 110 +++++++-- src/backend/access/transam/xlog.c | 43 +++- src/backend/access/transam/xlogfuncs.c | 55 ++--- src/backend/access/transam/xlogrecovery.c | 75 ++++++- .../libpqwalreceiver/libpqwalreceiver.c | 6 +- src/backend/replication/walreceiver.c | 99 +++++---- src/bin/pg_basebackup/pg_basebackup.c | 2 +- src/bin/pg_basebackup/receivelog.c | 2 +- src/bin/pg_basebackup/streamutil.c | 2 +- src/include/access/timeline.h | 4 +- src/include/access/xlogrecovery.h | 10 + src/include/catalog/pg_proc.dat | 6 + src/test/recovery/meson.build | 1 + src/test/recovery/t/061_promote_timeline.pl | 208 ++++++++++++++++++ src/test/recovery/t/restore_probe | 8 + 16 files changed, 594 insertions(+), 94 deletions(-) create mode 100644 src/test/recovery/t/061_promote_timeline.pl create mode 100644 src/test/recovery/t/restore_probe diff --git a/doc/src/sgml/func/func-admin.sgml b/doc/src/sgml/func/func-admin.sgml index 64b0e7bb972..13f75bd45b5 100644 --- a/doc/src/sgml/func/func-admin.sgml +++ b/doc/src/sgml/func/func-admin.sgml @@ -816,9 +816,10 @@ postgres=# SELECT '0/0'::pg_lsn + pd.segment_number * ps.setting::int + :offset returns true if promotion is successful and false otherwise. If wait is set to false, the - function returns true immediately after sending a - SIGUSR1 signal to the postmaster to trigger - promotion. + function returns true immediately after requesting + promotion. A timeout, transaction rollback, or client disconnect does + not cancel the request. An uncompleted SQL promotion request is not + retained across a server restart. This function is restricted to superusers by default, but other users @@ -826,6 +827,56 @@ postgres=# SELECT '0/0'::pg_lsn + pd.segment_number * ps.setting::int + :offset + + + pg_promote ( wait boolean, wait_seconds integer, new_timeline bigint ) + boolean + + + Promotes a standby using an externally allocated timeline ID instead + of probing the WAL archive for an unused ID. The caller must allocate + an ID that has never been used for this database system, including by + another server or an unsuccessful promotion attempt. The ID must be + between 2 and 4294967295 and greater than the recovery target timeline + when recovery finishes. Local WAL and history file conflicts are + rejected, but PostgreSQL cannot check cluster-wide uniqueness without + contacting the archive. This function does not elect a primary or + prevent another server from accepting writes. + + + The parent history must already be present in pg_wal + (except for timeline 1, which has no history file). Neither timeline + allocation nor writing the new history invokes + restore_command. This does not interrupt an existing + restore command or skip fetching and replaying WAL before promotion. + History archiving remains asynchronous. + + + A concurrent request cannot change the timeline of an accepted + promotion. A request without new_timeline + can wait for an existing request, including one with an explicit ID. + All three arguments are required when specifying a timeline ID. + Validation is repeated when recovery ends; if recovery has advanced + beyond the requested ID, promotion fails rather than choosing a + different ID. The wait and restart behavior is the same as for the + other form of pg_promote. + + + IDs need not be consecutive. Streaming standbys can follow such a + promotion, but archive-only recovery with + recovery_target_timeline = 'latest' cannot discover + histories beyond a gap that is not covered by a local history file. + Specify recovery_target_timeline explicitly in that + case. Older streaming clients may also assume consecutive IDs. + + + This overload is restricted to superusers by default. Permission to + call the other overload does not grant permission to allocate IDs. + pg_ctl promote continues to allocate an ID + automatically; it cannot supply new_timeline. + + + diff --git a/src/backend/access/transam/timeline.c b/src/backend/access/transam/timeline.c index d80c8ffe0a7..27e15cf1a73 100644 --- a/src/backend/access/transam/timeline.c +++ b/src/backend/access/transam/timeline.c @@ -43,26 +43,39 @@ #include "storage/fd.h" #include "utils/wait_event.h" +static List *readTimeLineHistoryInternal(TimeLineID targetTLI, + bool restoreHistory); + /* - * Copies all timeline history files with id's between 'begin' and 'end' - * from archive to pg_wal. + * Copy ancestor history files of 'end' with IDs >= 'begin' from the archive + * to pg_wal. The history for 'end' itself has already been obtained. */ void restoreTimeLineHistoryFiles(TimeLineID begin, TimeLineID end) { char path[MAXPGPATH]; char histfname[MAXFNAMELEN]; - TimeLineID tli; + List *history; + ListCell *lc; + + if (begin >= end) + return; - for (tli = begin; tli < end; tli++) + /* IDs allocated externally need not be consecutive. */ + history = readTimeLineHistoryInternal(end, false); + foreach(lc, history) { - if (tli == 1) + TimeLineHistoryEntry *entry = lfirst(lc); + TimeLineID tli = entry->tli; + + if (tli == 1 || tli < begin || tli >= end) continue; TLHistoryFileName(histfname, tli); if (RestoreArchivedFile(path, histfname, "RECOVERYHISTORY", 0, false)) KeepFileRestoredFromArchive(path, histfname); } + list_free_deep(history); } /* @@ -75,6 +88,13 @@ restoreTimeLineHistoryFiles(TimeLineID begin, TimeLineID end) */ List * readTimeLineHistory(TimeLineID targetTLI) +{ + return readTimeLineHistoryInternal(targetTLI, true); +} + +/* Also used for a history already obtained locally, without another fetch. */ +static List * +readTimeLineHistoryInternal(TimeLineID targetTLI, bool restoreHistory) { List *result; char path[MAXPGPATH]; @@ -94,7 +114,7 @@ readTimeLineHistory(TimeLineID targetTLI) return list_make1(entry); } - if (ArchiveRecoveryRequested) + if (restoreHistory && ArchiveRecoveryRequested) { TLHistoryFileName(histfname, targetTLI); fromArchive = @@ -257,24 +277,41 @@ existsTimeLineHistory(TimeLineID probeTLI) /* * Find the newest existing timeline, assuming that startTLI exists. * - * Note: while this is somewhat heuristic, it does positively guarantee - * that (result + 1) is not a known timeline, and therefore it should - * be safe to assign that ID to a new timeline. + * This is only a heuristic: a failed archive fetch cannot distinguish a + * missing history from an unavailable archive, and concurrent promotions + * can choose the same ID. Also, PG_UINT32_MAX has no successor. */ TimeLineID findNewestTimeLine(TimeLineID startTLI) { TimeLineID newestTLI; TimeLineID probeTLI; + DIR *dir; + struct dirent *de; /* - * The algorithm is just to probe for the existence of timeline history - * files. XXX is it useful to allow gaps in the sequence? + * A streaming upstream can supply a history file beyond a gap in the + * sequence. Find the newest local history first, then probe the archive. + * The archive interface cannot list files, so gaps there still require an + * explicit recovery_target_timeline or a streaming connection. */ newestTLI = startTLI; + dir = AllocateDir(XLOGDIR); + while ((de = ReadDir(dir, XLOGDIR)) != NULL) + { + if (IsTLHistoryFileName(de->d_name)) + { + TimeLineID tli; - for (probeTLI = startTLI + 1;; probeTLI++) + sscanf(de->d_name, "%08X", &tli); + newestTLI = Max(newestTLI, tli); + } + } + FreeDir(dir); + + while (newestTLI < PG_UINT32_MAX) { + probeTLI = newestTLI + 1; if (existsTimeLineHistory(probeTLI)) { newestTLI = probeTLI; /* probeTLI exists */ @@ -289,6 +326,48 @@ findNewestTimeLine(TimeLineID startTLI) return newestTLI; } +/* + * Validate an externally allocated timeline against local state only. The + * allocator, not restore_command, is responsible for cluster-wide uniqueness. + * Check WAL as well as history: an interrupted promotion can leave either. + * Startup repeats this check after recovery, before creating any new files. + */ +void +checkTimeLineID(TimeLineID newTLI, TimeLineID minTLI, int elevel) +{ + DIR *dir; + struct dirent *de; + bool conflict = false; + + if (newTLI <= minTLI) + ereport(elevel, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("new timeline ID %u must be greater than timeline ID %u", + newTLI, minTLI))); + + dir = AllocateDir(XLOGDIR); + while ((de = ReadDir(dir, XLOGDIR)) != NULL) + { + if (IsTLHistoryFileName(de->d_name) || + IsXLogFileName(de->d_name) || IsPartialXLogFileName(de->d_name)) + { + TimeLineID tli; + + sscanf(de->d_name, "%08X", &tli); + if (tli == newTLI) + { + conflict = true; + break; + } + } + } + FreeDir(dir); + if (conflict) + ereport(elevel, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("timeline ID %u is already present in pg_wal", newTLI))); +} + /* * Create a new timeline history file. * @@ -303,7 +382,8 @@ findNewestTimeLine(TimeLineID startTLI) */ void writeTimeLineHistory(TimeLineID newTLI, TimeLineID parentTLI, - XLogRecPtr switchpoint, const char *reason) + XLogRecPtr switchpoint, const char *reason, + bool restoreHistory) { char path[MAXPGPATH]; char tmppath[MAXPGPATH]; @@ -332,7 +412,7 @@ writeTimeLineHistory(TimeLineID newTLI, TimeLineID parentTLI, /* * If a history file exists for the parent, copy it verbatim */ - if (ArchiveRecoveryRequested) + if (restoreHistory && ArchiveRecoveryRequested) { TLHistoryFileName(histfname, parentTLI); RestoreArchivedFile(path, histfname, "RECOVERYHISTORY", 0, false); @@ -343,7 +423,7 @@ writeTimeLineHistory(TimeLineID newTLI, TimeLineID parentTLI, srcfd = OpenTransientFile(path, O_RDONLY); if (srcfd < 0) { - if (errno != ENOENT) + if (errno != ENOENT || (!restoreHistory && parentTLI != 1)) ereport(ERROR, (errcode_for_file_access(), errmsg("could not open file \"%s\": %m", path))); diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index 8636ad9ab2c..d8324a7a57b 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -6658,7 +6658,45 @@ StartupXLOG(void) newTLI = endOfRecoveryInfo->lastRecTLI; if (ArchiveRecoveryRequested) { - newTLI = findNewestTimeLine(recoveryTargetTLI) + 1; + TimeLineID requestedTLI = GetPromotionTimeLine(); + + if (requestedTLI != 0) + { + char path[MAXPGPATH]; + int fd; + + checkTimeLineID(requestedTLI, + Max(recoveryTargetTLI, endOfRecoveryInfo->lastRecTLI), + FATAL); + newTLI = requestedTLI; + + /* + * Recovery must already have obtained the parent's history. Do + * not call restore_command again, or silently omit ancestors if + * the local file is missing. Check before removing signal files. + */ + if (recoveryTargetTLI != 1) + { + TLHistoryFilePath(path, recoveryTargetTLI); + fd = OpenTransientFile(path, O_RDONLY); + if (fd < 0) + ereport(FATAL, + (errcode_for_file_access(), + errmsg("could not open file \"%s\": %m", path))); + if (CloseTransientFile(fd) != 0) + ereport(FATAL, + (errcode_for_file_access(), + errmsg("could not close file \"%s\": %m", path))); + } + } + else + { + newTLI = findNewestTimeLine(recoveryTargetTLI); + if (newTLI == PG_UINT32_MAX) + ereport(FATAL, + (errmsg("no more timeline IDs are available"))); + newTLI++; + } ereport(LOG, (errmsg("selected new timeline ID: %u", newTLI))); @@ -6690,7 +6728,8 @@ StartupXLOG(void) * between here and writing the end-of-recovery record. */ writeTimeLineHistory(newTLI, recoveryTargetTLI, - EndOfLog, endOfRecoveryInfo->recoveryStopReason); + EndOfLog, endOfRecoveryInfo->recoveryStopReason, + requestedTLI == 0); ereport(LOG, (errmsg("archive recovery complete"))); diff --git a/src/backend/access/transam/xlogfuncs.c b/src/backend/access/transam/xlogfuncs.c index 1f52bf7b420..4c622b1ffa6 100644 --- a/src/backend/access/transam/xlogfuncs.c +++ b/src/backend/access/transam/xlogfuncs.c @@ -47,6 +47,8 @@ static StringInfo tablespace_map = NULL; /* Session-level context for the SQL-callable backup functions */ static MemoryContext backupcontext = NULL; +static bool pg_promote_internal(bool wait, int wait_seconds, TimeLineID newTLI); + /* * Return a string constant representing the recovery pause state. This is @@ -690,7 +692,29 @@ pg_promote(PG_FUNCTION_ARGS) { bool wait = PG_GETARG_BOOL(0); int wait_seconds = PG_GETARG_INT32(1); - FILE *promote_file; + + PG_RETURN_BOOL(pg_promote_internal(wait, wait_seconds, 0)); +} + +/* Promote using a timeline allocated by an external coordinator. */ +Datum +pg_promote_timeline(PG_FUNCTION_ARGS) +{ + bool wait = PG_GETARG_BOOL(0); + int wait_seconds = PG_GETARG_INT32(1); + int64 newTLI = PG_GETARG_INT64(2); + + if (newTLI <= 1 || newTLI > PG_UINT32_MAX) + ereport(ERROR, + (errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE), + errmsg("\"new_timeline\" must be between 2 and 4294967295"))); + + PG_RETURN_BOOL(pg_promote_internal(wait, wait_seconds, (TimeLineID) newTLI)); +} + +static bool +pg_promote_internal(bool wait, int wait_seconds, TimeLineID newTLI) +{ TimestampTz end_time; if (!RecoveryInProgress()) @@ -704,32 +728,11 @@ pg_promote(PG_FUNCTION_ARGS) (errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE), errmsg("\"wait_seconds\" must not be negative or zero"))); - /* 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))); - - /* signal the postmaster */ - if (kill(PostmasterPid, SIGUSR1) != 0) - { - (void) unlink(PROMOTE_SIGNAL_FILE); - ereport(ERROR, - (errcode(ERRCODE_SYSTEM_ERROR), - errmsg("failed to send signal to postmaster: %m"))); - } + RequestPromotion(newTLI); /* return immediately if waiting was not requested */ if (!wait) - PG_RETURN_BOOL(true); + return true; /* wait for the amount of time wanted until promotion */ end_time = TimestampTzPlusSeconds(GetCurrentTimestamp(), wait_seconds); @@ -740,7 +743,7 @@ pg_promote(PG_FUNCTION_ARGS) ResetLatch(MyLatch); if (!RecoveryInProgress()) - PG_RETURN_BOOL(true); + return true; CHECK_FOR_INTERRUPTS(); @@ -765,7 +768,7 @@ pg_promote(PG_FUNCTION_ARGS) "server did not promote within %d seconds", wait_seconds, wait_seconds))); - PG_RETURN_BOOL(false); + return false; } /* diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 20be788da78..532057532de 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -4452,16 +4452,89 @@ SetPromoteIsTriggered(void) LocalPromoteIsTriggered = true; } +/* + * Request promotion through shared memory. Zero asks startup to allocate the + * timeline as usual. Once a request has been accepted, callers may wait for + * it, but may not change its timeline. A pg_ctl request freezes the choice + * when startup observes it; an SQL request freezes it here. + * + * The request survives transaction abort and client disconnect, but not a + * server restart. Neither cancellation nor a wait timeout cancels promotion. + */ +void +RequestPromotion(TimeLineID newTLI) +{ + bool conflict = false; + bool validated = false; + + for (;;) + { + SpinLockAcquire(&XLogRecoveryCtl->info_lck); + if (XLogRecoveryCtl->promotionRequested || + XLogRecoveryCtl->SharedPromoteIsTriggered || + XLogRecoveryCtl->promotionTimelineSelected) + { + if (newTLI != 0 && newTLI != XLogRecoveryCtl->promotionTimeline) + conflict = true; + } + else if (newTLI != 0 && !validated) + { + TimeLineID replayTLI; + + /* Do I/O without the spinlock, then recheck for other requests. */ + SpinLockRelease(&XLogRecoveryCtl->info_lck); + (void) GetXLogReplayRecPtr(&replayTLI); + checkTimeLineID(newTLI, replayTLI, ERROR); + validated = true; + continue; + } + else + { + XLogRecoveryCtl->promotionTimeline = newTLI; + XLogRecoveryCtl->promotionRequested = true; + } + SpinLockRelease(&XLogRecoveryCtl->info_lck); + break; + } + + if (conflict) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("cannot change timeline of a promotion in progress"))); + + WakeupRecovery(); +} + +/* Freeze the choice before end-of-recovery processing creates any files. */ +TimeLineID +GetPromotionTimeLine(void) +{ + TimeLineID result; + + SpinLockAcquire(&XLogRecoveryCtl->info_lck); + XLogRecoveryCtl->promotionTimelineSelected = true; + result = XLogRecoveryCtl->promotionTimeline; + SpinLockRelease(&XLogRecoveryCtl->info_lck); + + return result; +} + /* * Check whether a promote request has arrived. */ static bool CheckForStandbyTrigger(void) { + bool requested; + if (LocalPromoteIsTriggered) return true; - if (IsPromoteSignaled() && CheckPromoteSignal()) + SpinLockAcquire(&XLogRecoveryCtl->info_lck); + requested = XLogRecoveryCtl->promotionRequested; + SpinLockRelease(&XLogRecoveryCtl->info_lck); + + if (requested || (IsPromoteSignaled() && CheckPromoteSignal())) { ereport(LOG, (errmsg("received promote request"))); RemovePromoteSignalFiles(); diff --git a/src/backend/replication/libpqwalreceiver/libpqwalreceiver.c b/src/backend/replication/libpqwalreceiver/libpqwalreceiver.c index 029990d9fce..4ed2663e8cd 100644 --- a/src/backend/replication/libpqwalreceiver/libpqwalreceiver.c +++ b/src/backend/replication/libpqwalreceiver/libpqwalreceiver.c @@ -453,7 +453,8 @@ libpqrcv_identify_system(WalReceiverConn *conn, TimeLineID *primary_tli, errdetail("Could not identify system: got %d rows and %d fields, expected %d rows and %d or more fields.", PQntuples(res), PQnfields(res), 1, 3))); primary_sysid = pstrdup(PQgetvalue(res, 0, 0)); - *primary_tli = pg_strtoint32(PQgetvalue(res, 0, 1)); + *primary_tli = uint32in_subr(PQgetvalue(res, 0, 1), NULL, + "timeline ID", NULL); /* Column 2 is the server's current WAL flush position */ if (server_lsn) @@ -709,7 +710,8 @@ libpqrcv_endstreaming(WalReceiverConn *conn, TimeLineID *next_tli) ereport(ERROR, (errcode(ERRCODE_PROTOCOL_VIOLATION), errmsg("unexpected result set after end-of-streaming"))); - *next_tli = pg_strtoint32(PQgetvalue(res, 0, 0)); + *next_tli = uint32in_subr(PQgetvalue(res, 0, 0), NULL, + "timeline ID", NULL); PQclear(res); /* the result set should be followed by CommandComplete */ diff --git a/src/backend/replication/walreceiver.c b/src/backend/replication/walreceiver.c index ce28ba623cd..dbebe810a0c 100644 --- a/src/backend/replication/walreceiver.c +++ b/src/backend/replication/walreceiver.c @@ -136,6 +136,7 @@ static StringInfoData reply_message; /* Prototypes for private functions */ static void WalRcvFetchTimeLineHistoryFiles(TimeLineID first, TimeLineID last); +static void WalRcvFetchTimeLineHistoryFile(TimeLineID tli); static void WalRcvWaitForStartPosition(XLogRecPtr *startpoint, TimeLineID *startpointTLI); static void WalRcvDie(int code, Datum arg); static void XLogWalRcvProcessMsg(unsigned char type, char *buf, Size len, @@ -819,59 +820,75 @@ WalRcvWaitForStartPosition(XLogRecPtr *startpoint, TimeLineID *startpointTLI) } /* - * Fetch any missing timeline history files between 'first' and 'last' - * (inclusive) from the server. + * Fetch missing history files on the ancestry of 'last', back to 'first'. + * Do not request every intervening ID: externally allocated IDs can have gaps. */ static void WalRcvFetchTimeLineHistoryFiles(TimeLineID first, TimeLineID last) { - TimeLineID tli; + List *history; + ListCell *lc; - for (tli = first; tli <= last; tli++) + /* endstreaming can return zero when no timeline switch occurred. */ + if (last < first) + return; + + WalRcvFetchTimeLineHistoryFile(last); + history = readTimeLineHistory(last); + foreach(lc, history) { - /* there's no history file for timeline 1 */ - if (tli != 1 && !existsTimeLineHistory(tli)) - { - char *fname; - char *content; - size_t len; - char expectedfname[MAXFNAMELEN]; + TimeLineHistoryEntry *entry = lfirst(lc); - ereport(LOG, - (errmsg("fetching timeline history file for timeline %u from primary server", - tli))); + if (entry->tli >= first && entry->tli != last) + WalRcvFetchTimeLineHistoryFile(entry->tli); + } + list_free_deep(history); +} - walrcv_readtimelinehistoryfile(wrconn, tli, &fname, &content, &len); +static void +WalRcvFetchTimeLineHistoryFile(TimeLineID tli) +{ + /* there's no history file for timeline 1 */ + if (tli != 1 && !existsTimeLineHistory(tli)) + { + char *fname; + char *content; + size_t len; + char expectedfname[MAXFNAMELEN]; - /* - * Check that the filename on the primary matches what we - * calculated ourselves. This is just a sanity check, it should - * always match. - */ - TLHistoryFileName(expectedfname, tli); - if (strcmp(fname, expectedfname) != 0) - ereport(ERROR, - (errcode(ERRCODE_PROTOCOL_VIOLATION), - errmsg_internal("primary reported unexpected file name for timeline history file of timeline %u", - tli))); + ereport(LOG, + (errmsg("fetching timeline history file for timeline %u from primary server", + tli))); - /* - * Write the file to pg_wal. - */ - writeTimeLineHistoryFile(tli, content, len); + walrcv_readtimelinehistoryfile(wrconn, tli, &fname, &content, &len); - /* - * Mark the streamed history file as ready for archiving if - * archive_mode is always. - */ - if (XLogArchiveMode != ARCHIVE_MODE_ALWAYS) - XLogArchiveForceDone(fname); - else - XLogArchiveNotify(fname); + /* + * Check that the filename on the primary matches what we calculated + * ourselves. This is just a sanity check, it should always match. + */ + TLHistoryFileName(expectedfname, tli); + if (strcmp(fname, expectedfname) != 0) + ereport(ERROR, + (errcode(ERRCODE_PROTOCOL_VIOLATION), + errmsg_internal("primary reported unexpected file name for timeline history file of timeline %u", + tli))); - pfree(fname); - pfree(content); - } + /* + * Write the file to pg_wal. + */ + writeTimeLineHistoryFile(tli, content, len); + + /* + * Mark the streamed history file as ready for archiving if + * archive_mode is always. + */ + if (XLogArchiveMode != ARCHIVE_MODE_ALWAYS) + XLogArchiveForceDone(fname); + else + XLogArchiveNotify(fname); + + pfree(fname); + pfree(content); } } diff --git a/src/bin/pg_basebackup/pg_basebackup.c b/src/bin/pg_basebackup/pg_basebackup.c index c3b87a19e76..8d63ab46e46 100644 --- a/src/bin/pg_basebackup/pg_basebackup.c +++ b/src/bin/pg_basebackup/pg_basebackup.c @@ -2014,7 +2014,7 @@ BaseBackup(char *compression_algorithm, char *compression_detail, * IDENTIFY_SYSTEM. */ if (PQnfields(res) >= 2) - starttli = atoi(PQgetvalue(res, 0, 1)); + starttli = strtoul(PQgetvalue(res, 0, 1), NULL, 10); else starttli = latesttli; PQclear(res); diff --git a/src/bin/pg_basebackup/receivelog.c b/src/bin/pg_basebackup/receivelog.c index 77a2b4458b3..dbe36db5062 100644 --- a/src/bin/pg_basebackup/receivelog.c +++ b/src/bin/pg_basebackup/receivelog.c @@ -724,7 +724,7 @@ ReadEndOfStreamingResult(PGresult *res, XLogRecPtr *startpos, uint32 *timeline) return false; } - *timeline = atoi(PQgetvalue(res, 0, 0)); + *timeline = strtoul(PQgetvalue(res, 0, 0), NULL, 10); if (!pg_parse_lsn(PQgetvalue(res, 0, 1), startpos)) { pg_log_error("could not parse next timeline's starting point \"%s\"", diff --git a/src/bin/pg_basebackup/streamutil.c b/src/bin/pg_basebackup/streamutil.c index 8086fde84db..1d41ef417fa 100644 --- a/src/bin/pg_basebackup/streamutil.c +++ b/src/bin/pg_basebackup/streamutil.c @@ -439,7 +439,7 @@ RunIdentifySystem(PGconn *conn, char **sysid, TimeLineID *starttli, /* Get timeline ID to start streaming from */ if (starttli != NULL) - *starttli = atoi(PQgetvalue(res, 0, 1)); + *starttli = strtoul(PQgetvalue(res, 0, 1), NULL, 10); /* Get LSN start position if necessary */ if (startpos != NULL) diff --git a/src/include/access/timeline.h b/src/include/access/timeline.h index 3aee3419a5c..1eff7d21723 100644 --- a/src/include/access/timeline.h +++ b/src/include/access/timeline.h @@ -32,8 +32,10 @@ typedef struct extern List *readTimeLineHistory(TimeLineID targetTLI); extern bool existsTimeLineHistory(TimeLineID probeTLI); extern TimeLineID findNewestTimeLine(TimeLineID startTLI); +extern void checkTimeLineID(TimeLineID newTLI, TimeLineID minTLI, int elevel); extern void writeTimeLineHistory(TimeLineID newTLI, TimeLineID parentTLI, - XLogRecPtr switchpoint, const char *reason); + XLogRecPtr switchpoint, const char *reason, + bool restoreHistory); extern void writeTimeLineHistoryFile(TimeLineID tli, const char *content, size_t size); extern void restoreTimeLineHistoryFiles(TimeLineID begin, TimeLineID end); extern bool tliInHistory(TimeLineID tli, List *expectedTLEs); diff --git a/src/include/access/xlogrecovery.h b/src/include/access/xlogrecovery.h index 8786b6d3a8e..d0fd5c939f3 100644 --- a/src/include/access/xlogrecovery.h +++ b/src/include/access/xlogrecovery.h @@ -77,6 +77,14 @@ typedef struct XLogRecoveryCtlData */ bool SharedPromoteIsTriggered; + /* + * SQL promotion request and frozen timeline choice, protected by + * info_lck. + */ + bool promotionRequested; + bool promotionTimelineSelected; + TimeLineID promotionTimeline; + /* * SharedRecoverySubtransInitialized indicates whether hot standby * initialization has started pg_subtrans. Protected by info_lck. @@ -225,6 +233,8 @@ extern TimestampTz GetCurrentChunkReplayStartTime(void); extern XLogRecPtr GetCurrentReplayRecPtr(TimeLineID *replayEndTLI); extern bool PromoteIsTriggered(void); +extern void RequestPromotion(TimeLineID newTLI); +extern TimeLineID GetPromotionTimeLine(void); extern bool CheckPromoteSignal(void); extern bool RecoverySubtransInitialized(void); extern void SetRecoverySubtransInitialized(void); diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 5e4c9e503f8..0106898b174 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -6865,6 +6865,12 @@ proargtypes => 'bool int4', proargnames => '{wait,wait_seconds}', proargdefaults => '{true,60}', prosrc => 'pg_promote', proacl => '{POSTGRES=X}' }, +{ oid => '8881', descr => 'promote standby server to specified timeline', + proname => 'pg_promote', provolatile => 'v', prorettype => 'bool', + proargtypes => 'bool int4 int8', + proargnames => '{wait,wait_seconds,new_timeline}', + prosrc => 'pg_promote_timeline', + proacl => '{POSTGRES=X}' }, { oid => '2848', descr => 'switch to new wal file', proname => 'pg_switch_wal', provolatile => 'v', prorettype => 'pg_lsn', proargtypes => '', prosrc => 'pg_switch_wal', proacl => '{POSTGRES=X}' }, diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index b6ac9dd07a7..0aafdade25f 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -69,6 +69,7 @@ tests += { 't/058_shutdown_crash_restart.pl', 't/059_remote_apply_status_interval.pl', 't/060_multixact_crashed_updater.pl', + 't/061_promote_timeline.pl', ], }, } diff --git a/src/test/recovery/t/061_promote_timeline.pl b/src/test/recovery/t/061_promote_timeline.pl new file mode 100644 index 00000000000..536924f0f7a --- /dev/null +++ b/src/test/recovery/t/061_promote_timeline.pl @@ -0,0 +1,208 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Externally allocated timeline IDs, including gaps and concurrent requests. +use strict; +use warnings FATAL => 'all'; +use FindBin; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->start; +my $injection = $ENV{enable_injection_points} eq 'yes' + && $primary->check_extension('injection_points'); +$primary->safe_psql('postgres', 'CREATE EXTENSION injection_points') + if $injection; +$primary->safe_psql( + 'postgres', q{ +CREATE TABLE data (i int); +INSERT INTO data VALUES (1); +CREATE ROLE promoter; +GRANT EXECUTE ON FUNCTION pg_promote(boolean, integer) TO promoter; +}); +$primary->backup('backup'); + +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +my $follower = PostgreSQL::Test::Cluster->new('follower'); +$follower->init_from_backup($primary, 'backup', has_streaming => 1); +$follower->append_conf('postgresql.conf', + "primary_conninfo = '" . $standby->connstr . "'\n"); + +# Log every archive probe and report the file missing. After accepting the +# request, promotion must not fetch a prospective timeline or parent history. +my $perl = $^X; +$perl =~ s!\\!/!g if $windows_os; +for my $node ($standby, $follower) +{ + $node->append_conf( + 'postgresql.conf', qq{ +restore_command = '"$perl" "$FindBin::RealBin/restore_probe" "%f"' +archive_mode = on +archive_command = '' +}); + $node->start; +} +$primary->wait_for_replay_catchup($standby); +$standby->wait_for_catchup($follower); + +is( $standby->safe_psql( + 'postgres', q{ +SELECT has_function_privilege('promoter', 'pg_promote(boolean,integer)', + 'EXECUTE') + AND NOT has_function_privilege('promoter', + 'pg_promote(boolean,integer,bigint)', + 'EXECUTE') +}), + 't', + 'existing grant does not authorize explicit timeline allocation'); + +for my $id (-1, 0, 1, 4294967296) +{ + my ($out, $err); + is( $standby->psql( + 'postgres', "SELECT pg_promote(true, 60, $id)", + stdout => \$out, + stderr => \$err), + 3, + "reject timeline $id"); + like($err, qr/new_timeline.*must be between/, 'range check'); +} + +my $collision = $standby->data_dir . '/pg_wal/000000630000000000000000'; +append_to_file($collision, 'existing WAL'); +my ($out, $err); +is( $standby->psql( + 'postgres', 'SELECT pg_promote(true, 60, 99)', + stdout => \$out, + stderr => \$err), + 3, + 'reject existing local WAL'); +like($err, qr/timeline ID 99 is already present/, 'local conflict reported'); +unlink($collision) or die "unlink $collision: $!"; + +$primary->stop; + +if ($injection) +{ + $standby->safe_psql('postgres', + "SELECT injection_points_attach('promotion-after-wal-segment-cleanup', 'wait')" + ); +} +is( $standby->safe_psql( + 'postgres', + 'SELECT pg_promote(wait => false, wait_seconds => 60, new_timeline => 42)' + ), + 't', + 'accept explicit promotion'); + +if ($injection) +{ + $standby->wait_for_event('startup', + 'promotion-after-wal-segment-cleanup'); + is($standby->safe_psql('postgres', 'SELECT pg_promote(false, 60, 42)'), + 't', 'same request can be repeated'); + is( $standby->psql( + 'postgres', 'SELECT pg_promote(false, 60, 43)', + stdout => \$out, + stderr => \$err), + 3, + 'reject conflicting request'); + like( + $err, + qr/cannot change timeline/, + 'conflict does not replace request'); + is( $standby->safe_psql( + 'postgres', 'SELECT pg_promote(wait_seconds => 1)'), + 'f', + 'legacy call waits for existing request and times out'); + $standby->safe_psql('postgres', + "SELECT injection_points_wakeup('promotion-after-wal-segment-cleanup')" + ); +} +$standby->poll_query_until('postgres', 'SELECT NOT pg_is_in_recovery()') + or die 'promotion did not complete'; +is( $standby->safe_psql( + 'postgres', + 'SELECT left(pg_walfile_name(pg_current_wal_insert_lsn()), 8)'), + '0000002A', + 'requested timeline used despite wait timeout'); +ok(-f $standby->data_dir . '/pg_wal/0000002A.history', 'new history exists'); + +sub no_history_fetch_after_request +{ + my ($node, $id) = @_; + my $log = slurp_file($node->logfile); + my ($after) = $log =~ /received promote request\r?\n(.*)/s; + ok(defined($after), "promotion to timeline $id logged"); + unlike( + $after // '', + qr/restore probe: .*\.history/, + 'no history fetch after accepting explicit promotion'); +} +no_history_fetch_after_request($standby, 42); + +$standby->safe_psql('postgres', 'INSERT INTO data VALUES (2)'); +$standby->wait_for_replay_catchup($follower); +is($follower->safe_psql('postgres', 'SELECT sum(i) FROM data'), + 3, 'streaming follower crosses a gap in timeline IDs'); + +is( $follower->psql( + 'postgres', 'SELECT pg_promote(true, 60, 41)', + stdout => \$out, + stderr => \$err), + 3, + 'reject ID below replay timeline'); +like($err, qr/must be greater than timeline ID 42/, 'monotonicity checked'); + +# Use the unsigned half of the TLI range and a parent other than timeline 1. +$standby->stop; +is( $follower->safe_psql( + 'postgres', 'SELECT pg_promote(true, 300, 2147483648)'), + 't', + 'promote with an ID above signed int32 range'); +like(slurp_file($follower->data_dir . '/pg_wal/80000000.history'), + qr/^42\t/m, 'parent timeline retained in new history'); +no_history_fetch_after_request($follower, 2147483648); +$follower->safe_psql('postgres', 'INSERT INTO data VALUES (3)'); +$follower->restart; +is($follower->safe_psql('postgres', 'SELECT sum(i) FROM data'), + 6, 'restart after explicit promotion preserves data'); + +# Exercise both frontend and walreceiver parsing of unsigned timeline IDs. +# Successful promotion alone would not test IDENTIFY_SYSTEM on that timeline. +$follower->backup('unsigned_tli'); +my $unsigned_follower = PostgreSQL::Test::Cluster->new('unsigned_follower'); +$unsigned_follower->init_from_backup($follower, 'unsigned_tli', + has_streaming => 1); +$unsigned_follower->start; +$follower->safe_psql('postgres', 'INSERT INTO data VALUES (4)'); +$follower->wait_for_replay_catchup($unsigned_follower); +is($unsigned_follower->safe_psql('postgres', 'SELECT sum(i) FROM data'), + 10, 'new standby connects on an unsigned timeline ID'); + +# A running walreceiver also parses the timeline in the end-of-stream reply. +$follower->stop; +$follower->set_standby_mode; +$follower->start; +$follower->promote; +$follower->safe_psql('postgres', 'INSERT INTO data VALUES (5)'); +$follower->wait_for_replay_catchup($unsigned_follower); +is($unsigned_follower->safe_psql('postgres', 'SELECT sum(i) FROM data'), + 15, 'streaming follows a switch above signed int32 range'); + +# The original API and pg_ctl remain usable without a timeline argument. +# Reuse the fenced original primary as an isolated standby. +$primary->set_standby_mode; +$primary->start; +is($primary->safe_psql('postgres', "SELECT pg_promote('true', 300)"), + 't', 'original SQL signature still promotes'); +is( $primary->safe_psql( + 'postgres', + 'SELECT left(pg_walfile_name(pg_current_wal_insert_lsn()), 8)'), + '00000002', + 'automatic timeline allocation retained'); + +done_testing(); diff --git a/src/test/recovery/t/restore_probe b/src/test/recovery/t/restore_probe new file mode 100644 index 00000000000..388dbc256c6 --- /dev/null +++ b/src/test/recovery/t/restore_probe @@ -0,0 +1,8 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# A restore_command that records the request and reports a missing file. +use strict; +use warnings FATAL => 'all'; + +print STDERR "restore probe: $ARGV[0]\n"; +exit 1; -- That's all, folks. May the source be with you.