From b296e8db18264114049db95c634dbaef18a3c467 Mon Sep 17 00:00:00 2001
From: shihao zhong <zhong950419@gmail.com>
Date: Wed, 28 Oct 2026 23:14:07 -0400
Subject: [PATCH v1 2/2] Add test for checkpoint during ROLLBACK PREPARED

Add an injection point between the abort record flush and the pg_xact
update in RecordTransactionAbortPrepared(), and a TAP test that runs a
checkpoint and crashes there.

Backpatch-through: 18
---
 src/backend/access/transam/twophase.c         |  5 +
 src/test/recovery/meson.build                 |  1 +
 .../t/061_abort_prepared_checkpoint.pl        | 93 +++++++++++++++++++
 3 files changed, 99 insertions(+)
 create mode 100644 src/test/recovery/t/061_abort_prepared_checkpoint.pl

diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c
index 77c8dae0fdc..28820348fb3 100644
--- a/src/backend/access/transam/twophase.c
+++ b/src/backend/access/transam/twophase.c
@@ -2492,6 +2492,9 @@ RecordTransactionAbortPrepared(TransactionId xid,
 		elog(PANIC, "cannot abort transaction %u, it was already committed",
 			 xid);
 
+	/* Load the injection point before entering the critical section */
+	INJECTION_POINT_LOAD("abort-prepared-before-clog");
+
 	START_CRIT_SECTION();
 
 	/*
@@ -2524,6 +2527,8 @@ RecordTransactionAbortPrepared(TransactionId xid,
 	/* Always flush, since we're about to remove the 2PC state file */
 	XLogFlush(recptr);
 
+	INJECTION_POINT_CACHED("abort-prepared-before-clog", NULL);
+
 	/*
 	 * Mark the transaction aborted in clog.  Recovery relies on this to
 	 * discard a state file that a checkpoint wrote before we removed it.
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index b6ac9dd07a7..865169d2698 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_abort_prepared_checkpoint.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/061_abort_prepared_checkpoint.pl b/src/test/recovery/t/061_abort_prepared_checkpoint.pl
new file mode 100644
index 00000000000..41bd726e8c3
--- /dev/null
+++ b/src/test/recovery/t/061_abort_prepared_checkpoint.pl
@@ -0,0 +1,93 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test that a checkpoint starting while ROLLBACK PREPARED has written its
+# abort record, but has not yet updated pg_xact, waits for the abort to
+# finish.  Otherwise, the checkpoint could set its redo pointer after the
+# abort record and still write out the two-phase state file without the
+# abort in pg_xact.  After a crash, the abort record would not be replayed
+# and the transaction would come back as prepared.
+
+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 $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf('postgresql.conf', 'max_prepared_transactions = 5');
+$node->start;
+
+if (!$node->check_extension('injection_points'))
+{
+	plan skip_all => 'Extension injection_points not installed';
+}
+
+$node->safe_psql(
+	'postgres', q(
+	CREATE EXTENSION injection_points;
+	CREATE TABLE abort_prep (id int);
+	BEGIN;
+	INSERT INTO abort_prep VALUES (1);
+	PREPARE TRANSACTION 'abort_prep_gxact';
+));
+
+# Stop ROLLBACK PREPARED after its abort record has been flushed, before
+# pg_xact is updated.
+my $rollback = $node->background_psql('postgres', on_error_stop => 0);
+$rollback->query_safe(
+	q(SELECT injection_points_set_local();
+	  SELECT injection_points_attach('abort-prepared-before-clog', 'wait');));
+$rollback->query_until(
+	qr/starting_rollback/, q(
+	\echo starting_rollback
+	ROLLBACK PREPARED 'abort_prep_gxact';
+));
+$node->wait_for_event('client backend', 'abort-prepared-before-clog');
+
+my $redo_before =
+  $node->safe_psql('postgres', 'SELECT redo_lsn FROM pg_control_checkpoint()');
+
+my $checkpoint = $node->background_psql('postgres', on_error_stop => 0);
+$checkpoint->query_until(
+	qr/starting_checkpoint/, q(
+	\echo starting_checkpoint
+	CHECKPOINT;
+));
+
+# Wait until the checkpoint either waits for the rollback, or completes.
+$node->poll_query_until(
+	'postgres', qq(
+	SELECT EXISTS (SELECT 1 FROM pg_stat_activity
+				   WHERE backend_type = 'checkpointer' AND
+						 wait_event = 'CheckpointDelayStart') OR
+		   (SELECT redo_lsn FROM pg_control_checkpoint()) > '$redo_before'
+))
+  or die "timed out waiting for the checkpoint";
+
+is( $node->safe_psql(
+		'postgres',
+		'SELECT redo_lsn FROM pg_control_checkpoint()'),
+	$redo_before,
+	'checkpoint does not complete while ROLLBACK PREPARED is in progress');
+
+# Crash while the rollback is still waiting.  Its abort record is flushed,
+# so the transaction must not be prepared after recovery.
+$node->stop('immediate');
+$rollback->{run}->finish;
+$checkpoint->{run}->finish;
+$node->start;
+
+is( $node->safe_psql('postgres', 'SELECT count(*) FROM pg_prepared_xacts'),
+	'0', 'rolled back transaction is not prepared after crash recovery');
+is($node->safe_psql('postgres', 'SELECT count(*) FROM abort_prep'),
+	'0', 'row of rolled back transaction is not visible');
+
+$node->stop;
+done_testing();
-- 
2.37.1 (Apple Git-137.1)

