[PATCH] Fix timeline history after recovery stops on an ancestor
Hackorum builds and tests every patch posted to the lists, not only commitfest submissions. This is Hackorum's own CI rather than the PostgreSQL project's, and it is still under testing - please report anything that looks wrong.
You can run a PostgreSQL built from this patch straight from Docker, with no checkout and no build:
docker run --rm -p 5432:5432 ghcr.io/hackorum-dev/postgres-patch:t253263psql -h localhost -U postgresBuilt from patchset v5 (message #5), September 20, 2026 at 11:13 AM.
Every patchset is also pushed to a branch of our PostgreSQL fork, so you can check out the same tree CI built. Without a PostgreSQL checkout:
git clone --branch t253263_5 https://github.com/hackorum-dev/postgres.gitIn a checkout you already have, add the fork once:
git remote add hackorum https://github.com/hackorum-dev/postgres.gitthen, for this patchset and every later one:
git fetch hackorum t253263_5 && git checkout t253263_5Patchset v5 (message #5) is on t253263_5
Hi Stepan,
I looked at this and I think the diagnosis is right.
When archive recovery stops on an ancestor before the switch into
recoveryTargetTLI, the end-of-recovery path already treats
endOfRecoveryInfo->lastRecTLI as the timeline we are leaving
(PrevTimeLineID / the end-of-recovery record). writeTimeLineHistory()
was the odd one out: it still recorded recoveryTargetTLI as the parent,
so the new .history file copied a later switch and then appended an
earlier switchpoint. That is not a usable ancestry for anything that
walks timeline history.
Using lastRecTLI as the parent matches what recovery actually did.
endOfLogTLI would be the wrong substitute here: in the 028 scenario the
last records are still on timeline 1, even though they were read from a
segment whose filename carries timeline 2. Leaving
findNewestTimeLine(recoveryTargetTLI) + 1 alone also looks correct; the
new TLI must be unused in the archive, while the parent is a separate
question.
Heikki, Michael — you've spent a lot of time in this area (028 itself,
the "follow last replayed TLI" idea in walsenders, and the surrounding
recovery/timeline maintenance). If you have a moment, it would be
good to hear whether using lastRecTLI as the history parent is the
right long-term shape, or whether I'm missing a case where
recoveryTargetTLI is still required.
This also seems adjacent to the recent archive-recovery / timeline
threads [1]/messages/by-id/85386EF6-16B7-4D62-86BE-526A10F93825@yandex-team.ru[2]/messages/by-id/CA+Tgmobr27GpKDZx3_ezW2+C5_g18i+jSK3sGF_cR-_ESv5N5A@mail.gmail.com: once .history lies about ancestry, consumers that
trust it are on thin ice.
[1]: /messages/by-id/85386EF6-16B7-4D62-86BE-526A10F93825@yandex-team.ru
/messages/by-id/85386EF6-16B7-4D62-86BE-526A10F93825@yandex-team.ru
[2]: /messages/by-id/CA+Tgmobr27GpKDZx3_ezW2+C5_g18i+jSK3sGF_cR-_ESv5N5A@mail.gmail.com
/messages/by-id/CA+Tgmobr27GpKDZx3_ezW2+C5_g18i+jSK3sGF_cR-_ESv5N5A@mail.gmail.com
пт, 31 июл. 2026 г. в 13:36, Филиппов Степан <stepan.filippov@yandex.ru>:
Hi hackers,
While working on WAL verification, we found a case where a timeline
history file does not describe the sequence of timeline switches that
actually took place.Suppose timeline 2 forked from timeline 1 at LSN B. We then perform
PITR with recovery_target_timeline = 'latest', but stop at a restore
point A on timeline 1, where A < B. PostgreSQL allocates timeline 3,
but currently records recoveryTargetTLI, i.e. timeline 2, as its
parent. The resulting history looks like this:cat 000002.history:
1 B ...
2 A ...The switchpoints run backwards. The actual ancestry is a direct fork
from timeline 1 to timeline 3 at A. A consumer such as a WAL verifier
cannot reconstruct that ancestry from the history file.The attached patch uses the timeline of the last replayed WAL record
as the parent of the new timeline. It also adjusts 028_pitr_timelines.pl
to check the generated history file.This came up while looking at the following related discussions [0] & [1]
I think this is a correctness issue and should be backpatched.
[0]
/messages/by-id/CA+Tgmobr27GpKDZx3_ezW2+C5_g18i+jSK3sGF_cR-_ESv5N5A@mail.gmail.com[1]
/messages/by-id/85386EF6-16B7-4D62-86BE-526A10F93825@yandex-team.ruRegards,
Stepan Filippov,
Yandex Cloud.
--
Regards,
Rachitskiy Andrey
Hi Stepan, Andrey,
I agree that lastRecTLI is the right parent here. It is already used as
PrevTimeLineID for the end-of-recovery record. endOfLogTLI would not be
a substitute: in the 028 scenario it is the timeline in the segment
name, not the timeline of the last replayed record.
It seems to me that 002_archiving.pl has a race. Generating and
archiving WAL on timeline 2 does not guarantee that standby2 replays it
before promotion. It can still be promoted while lastRecTLI is 1, so
the test would not prove that the restored timeline 2 history was used.
I think it needs to wait for the recorded timeline_2_lsn before calling
promote(), for example:
$caughtup_query =
"SELECT '$timeline_2_lsn'::pg_lsn <= pg_last_wal_replay_lsn()";
$node_standby2->poll_query_until('postgres', $caughtup_query)
or die "Timed out while waiting for standby2 to reach timeline 2";
Thank you!
Best regards, Andrey Borodin.
<div> </div><div> </div><div>27.08.2026, 16:17, "Andrey Borodin" <x4mmm@yandex-team.ru>:</div><div>> It seems to me that 002_archiving.pl has a race. Generating and<br />> archiving WAL on timeline 2 does not guarantee that standby2 replays it<br />> before promotion. It can still be promoted while lastRecTLI is 1, so<br />> the test would not prove that the restored timeline 2 history was used.<br />> I think it needs to wait for the recorded timeline_2_lsn before calling<br />> promote(), for example:<br />><br />> $caughtup_query =<br />> "SELECT '$timeline_2_lsn'::pg_lsn <= pg_last_wal_replay_lsn()";<br />> $node_standby2->poll_query_until('postgres', $caughtup_query)<br />> or die "Timed out while waiting for standby2 to reach timeline 2";</div><div> </div><div>Hi, Andrey!<br /> </div><div>Thank you for the review. You are right, there is a race.</div><div> </div><div>In v2 i added suggested query to wait timeline_2_lsn. This will help<br />to avoid promotion before timeline2 is applied.</div><div> </div><div>Thank you!<br /><br />Best regards, Stepan Filippov.<br /> </div>