From ff4e3fe5c177b8900d0a2c9b1c27fde78bcdbeea Mon Sep 17 00:00:00 2001
From: Zhijie Hou <houzj.fnst@fujitsu.com>
Date: Wed, 2 Sep 2026 21:25:27 +0800
Subject: [PATCH 3/3] Test advancement of conflict detection slot with
 asynchronous commits

Exercise the retain_dead_tuples advancement machinery against a
publisher transaction committing with synchronous_commit = off: while
its commit record is unflushed, the subscriber's conflict detection
slot xmin must not advance past a concurrent local deletion, and the
conflict must be reported as update_deleted once the update is applied.

The unflushed window is held open deterministically by parking the
publisher's walwriter in a new injection point at the top of
XLogBackgroundFlush() (only the walwriter calls it) and delaying
checkpoints: the walsender waits for WAL to be flushed to disk before
sending it, and nothing else can flush the record meanwhile.  This
keeps the test free of platform-specific process control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 src/backend/access/transam/xlog.c        |  6 ++
 src/test/subscription/t/035_conflicts.pl | 95 +++++++++++++++++++++++-
 2 files changed, 100 insertions(+), 1 deletion(-)

diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c
index de4c96e135f..24fd3aeaeb4 100644
--- a/src/backend/access/transam/xlog.c
+++ b/src/backend/access/transam/xlog.c
@@ -3017,6 +3017,12 @@ XLogBackgroundFlush(void)
 	int			flushblocks;
 	TimeLineID	insertTLI;
 
+	/*
+	 * Only the WAL writer calls this, so this injection point can be used to
+	 * hold back background WAL flushing in tests.
+	 */
+	INJECTION_POINT("xlog-background-flush", NULL);
+
 	/* XLOG doesn't need flushing during recovery */
 	if (RecoveryInProgress())
 		return false;
diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index e77af772d19..20abc74d74e 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -558,7 +558,100 @@ if ($injection_points_supported != 0)
 	is($result, qq(t),
 		"pub UPDATE's timestamp is later than that of sub's DELETE");
 
-	# Re-enable the subscription for further tests
+	###########################################################################
+	# Check that a transaction committing asynchronously on the publisher (with
+	# synchronous_commit = off) is accounted for when advancing the
+	# non-removable transaction ID.  The publisher must report a WAL position
+	# covering the commit record of such a transaction even though the record
+	# has not been flushed yet; otherwise the conflict detection slot's xmin
+	# could advance past a local deletion before the concurrent remote update
+	# is applied, misclassifying an update_deleted conflict as update_missing.
+	###########################################################################
+
+	# Start with a clean slate on both nodes.  Note that the subscription
+	# from node A to node B is disabled above, so applying the deletion below
+	# on node B cannot close the unflushed window prematurely by flushing WAL
+	# on the publisher.
+	$node_B->safe_psql('postgres',
+		"TRUNCATE tab; INSERT INTO tab VALUES (7, 7);");
+	$node_B->wait_for_catchup($subname_AB);
+
+	# Park the publisher's walwriter inside XLogBackgroundFlush() and delay
+	# checkpoints, so that the asynchronously committed transaction below
+	# keeps its commit record inserted but unflushed: the walsender waits
+	# for the flush before sending the record, and nothing else can flush
+	# it.
+	$node_B->append_conf('postgresql.conf', "checkpoint_timeout = 1h");
+	$node_B->reload;
+
+	# Disable sub_tab: its apply worker is repeatedly restarting on the
+	# pending conf_tab_2_p1 conflict, and its startup transaction commits
+	# synchronously, which would flush WAL on the publisher.
+	$node_B->psql('postgres', "ALTER SUBSCRIPTION sub_tab DISABLE;");
+
+	$node_B->safe_psql('postgres',
+		"SELECT injection_points_attach('xlog-background-flush', 'wait');");
+	$node_B->wait_for_event('walwriter', 'xlog-background-flush');
+
+	$log_location = -s $node_A->logfile;
+
+	# Update the row on the publisher with an asynchronous commit.  The
+	# commit record is inserted but not flushed.
+	$node_B->safe_psql('postgres',
+		"SET synchronous_commit = off; UPDATE tab SET b = 8 WHERE a = 7;");
+
+	# Delete the same row locally on the subscriber.
+	$node_A->safe_psql('postgres', "DELETE FROM tab WHERE a = 7;");
+
+	# Remember the next transaction ID to be assigned.
+	$next_xid = $node_A->safe_psql('postgres', "SELECT txid_current() + 1;");
+
+	# Wait for the apply worker to request the publisher status for the
+	# advancement cycle triggered by the deletion.  The status round trip
+	# completes promptly on a local connection; after that, the advancement
+	# can only be waiting for the local flush to reach the reported WAL
+	# position, which covers the unflushed commit record.
+	$node_A->wait_for_log(qr/sending publisher status request message/,
+		$log_location);
+
+	# Give the worker a moment to process the reply, then verify that the
+	# slot's xmin did not advance past the local deletion.
+	sleep 1;
+
+	$result = $node_A->safe_psql('postgres',
+		"SELECT xmin::text::bigint < $next_xid FROM pg_replication_slots WHERE slot_name = 'pg_conflict_detection'"
+	);
+	is($result, 't',
+		"slot xmin does not advance while the publisher's commit record is unflushed"
+	);
+
+	# Unpark the walwriter so that the update is sent and applied.
+	$node_B->safe_psql('postgres',
+		"SELECT injection_points_wakeup('xlog-background-flush');
+		 SELECT injection_points_detach('xlog-background-flush');");
+	$node_B->append_conf('postgresql.conf', "checkpoint_timeout = 5min");
+	$node_B->reload;
+	$node_B->wait_for_catchup($subname_AB);
+
+	# The deletion must be detected as an update_deleted conflict rather
+	# than update_missing, because the conflict detection slot's xmin could
+	# not advance past it before the update was applied.
+	$logfile = slurp_file($node_A->logfile(), $log_location);
+	like(
+		$logfile,
+		qr/conflict detected on relation "public.tab": conflict=update_deleted.*
+.*DETAIL:.* Could not find the row to be updated: remote row \(7, 8\), replica identity full \(7, 7\).*
+.*The row to be updated was deleted locally in transaction [0-9]+ at .*/,
+		'update_deleted detected for asynchronously committed update on publisher'
+	);
+
+	unlike(
+		$logfile,
+		qr/conflict detected on relation "public.tab": conflict=update_missing/,
+		'update_missing is not reported');
+
+	# Re-enable the subscriptions for further tests
+	$node_B->psql('postgres', "ALTER SUBSCRIPTION sub_tab ENABLE;");
 	$node_B->psql('postgres', "ALTER SUBSCRIPTION $subname_BA ENABLE;");
 }
 
-- 
2.47.3

