From ec3ed422c4e3fd70d46ef5a0df8391c6a32848c8 Mon Sep 17 00:00:00 2001
From: Maxim Orlov <orlovmg@gmail.com>
Date: Mon, 26 Oct 2026 15:49:41 +0300
Subject: [PATCH v1 2/3] Add 2pc race test between CheckPointTwoPhase() and
 EndPrepare()

---
 src/backend/access/transam/twophase.c   |  8 ++-
 src/test/recovery/t/062_2pc_lsn_race.pl | 81 +++++++++++++++++++++++++
 2 files changed, 87 insertions(+), 2 deletions(-)
 create mode 100644 src/test/recovery/t/062_2pc_lsn_race.pl

diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c
index 2129c75336a..e6e029050a9 100644
--- a/src/backend/access/transam/twophase.c
+++ b/src/backend/access/transam/twophase.c
@@ -1153,6 +1153,7 @@ EndPrepare(GlobalTransaction gxact)
 	TwoPhaseFileHeader *hdr;
 	StateFileChunk *record;
 	bool		replorigin;
+	XLogRecPtr	prepare_end_lsn;
 
 	/* Add the end sentinel to the list of 2PC records */
 	RegisterTwoPhaseRecord(TWOPHASE_RM_END_ID, 0,
@@ -1212,7 +1213,8 @@ EndPrepare(GlobalTransaction gxact)
 
 	XLogSetRecordFlags(XLOG_INCLUDE_ORIGIN);
 
-	gxact->prepare_end_lsn = XLogInsert(RM_XACT_ID, XLOG_XACT_PREPARE);
+	prepare_end_lsn = XLogInsert(RM_XACT_ID, XLOG_XACT_PREPARE);
+	gxact->prepare_end_lsn = prepare_end_lsn;
 
 	if (replorigin)
 	{
@@ -1258,13 +1260,15 @@ EndPrepare(GlobalTransaction gxact)
 
 	END_CRIT_SECTION();
 
+	INJECTION_POINT("twophase-prepare-before-syncrep", NULL);
+
 	/*
 	 * Wait for synchronous replication, if required.
 	 *
 	 * Note that at this stage we have marked the prepare, but still show as
 	 * running in the procarray (twice!) and continue to hold locks.
 	 */
-	SyncRepWaitForLSN(gxact->prepare_end_lsn, false);
+	SyncRepWaitForLSN(prepare_end_lsn, false);
 
 	records.tail = records.head = NULL;
 	records.num_chunks = 0;
diff --git a/src/test/recovery/t/062_2pc_lsn_race.pl b/src/test/recovery/t/062_2pc_lsn_race.pl
new file mode 100644
index 00000000000..6eb52f6d7b4
--- /dev/null
+++ b/src/test/recovery/t/062_2pc_lsn_race.pl
@@ -0,0 +1,81 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+#
+# Reproduce a race between CheckPointTwoPhase() changing ondisk/LSNs and
+# EndPrepare() reading those fields without syncing.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if ($ENV{enable_injection_points} ne 'yes')
+{
+	plan skip_all => 'Injection points not supported by this build';
+}
+
+my $node2 = PostgreSQL::Test::Cluster->new('twophase_endprepare_lsn_race');
+$node2->init(allows_streaming => 1);
+$node2->append_conf('postgresql.conf', q{
+	max_prepared_transactions = 10
+	log_checkpoints = on
+	synchronous_standby_names = 'nonexistent_standby'
+});
+$node2->start;
+
+if (!$node2->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+# The standby above never connects, so only the session that exercises
+# EndPrepare() may wait for it.  Setup commits must not.
+$node2->safe_psql('postgres', q{
+	SET synchronous_commit = local;
+	CREATE EXTENSION injection_points;
+});
+$node2->safe_psql(
+	'postgres',
+	q{SELECT injection_points_attach('twophase-prepare-before-syncrep',
+									 'wait')});
+
+# Pause PREPARE TRANSACTION in EndPrepare() right before it waits for the
+# standby, when the gxact is already visible to the checkpointer.
+my $prepare = $node2->background_psql('postgres');
+my $pid = $prepare->query_safe('SELECT pg_backend_pid()');
+chomp($pid);
+$prepare->query_until(
+	qr/prepare_started/, q{
+	\echo prepare_started
+	BEGIN;
+	CREATE TABLE twophase_endprepare_race_table (id integer);
+	PREPARE TRANSACTION 'twophase_endprepare_race_gid';
+	\echo prepare_finished
+});
+$node2->wait_for_event('client backend', 'twophase-prepare-before-syncrep');
+
+# The checkpoint writes the state file and clears prepare_end_lsn.
+$node2->safe_psql('postgres', 'CHECKPOINT');
+
+$node2->safe_psql('postgres',
+	q{SELECT injection_points_wakeup('twophase-prepare-before-syncrep')});
+
+# With a valid LSN PREPARE TRANSACTION waits for the standby forever.  With an
+# already cleared LSN it returns at once and the backend goes idle.  Wait for
+# either, then check which one it was.
+$node2->poll_query_until('postgres', qq{
+	SELECT wait_event = 'SyncRep' OR state = 'idle'
+	FROM pg_stat_activity WHERE pid = $pid})
+  or die "backend neither waits for the standby nor finished";
+is($node2->safe_psql('postgres',
+	qq{SELECT wait_event FROM pg_stat_activity WHERE pid = $pid}),
+	'SyncRep',
+	"PREPARE TRANSACTION waits for the synchronous standby");
+
+# Release the backend from the sync rep wait.
+$node2->safe_psql('postgres', qq{SELECT pg_cancel_backend($pid)});
+$prepare->quit;
+$node2->stop;
+
+done_testing();
-- 
2.55.0

