From 53c5c74e25c3b6f523f2f27d5850c6ce0059ff8b Mon Sep 17 00:00:00 2001
From: Hayato Kuroda <hayato@example.com>
Date: Thu, 8 Jan 2026 23:26:39 +0900
Subject: [PATCH v8] Fix errdetail for logical replication conflict

The output messages for logical replication conflicts had two issues: duplicate
remote rows could be printed multiple times for multiple_unique_conflicts, and
there were unnecessary if-else statements used for capitalizing a character.

This commit refactors the code that constructs the errdetail message for
replication conflicts.  For multiple_unique_conflicts, the common remote row now
appears only at the beginning of the message, with conflicting local rows listed
afterward. Manual capitalization is no longer necessary, as the sentence
structure has been revised.  Additionally, the data-printing function has been
refactored, and a few minor adjustments have been made to the DETAIL sentence
formatting for other conflicts to align with the revised
multiple_unique_conflicts message.

Author: Hayato Kuroda <kuroda.hayato@fujitsu.com>
Reviewed-by: Shveta Malik <shveta.malik@gmail.com>
Reviewed-by: Amit Kapila <amit.kapila16@gmail.com>
Reviewed-by: Masahiko Sawada <sawada.mshk@gmail.com>
Reviewed-by: Zhijie Hou <houzj.fnst@fujitsu.com>
---
 doc/src/sgml/logical-replication.sgml      |  23 +-
 src/backend/replication/logical/conflict.c | 251 ++++++++++++---------
 src/include/replication/conflict.h         |   2 +-
 src/test/subscription/t/001_rep_changes.pl |   6 +-
 src/test/subscription/t/013_partition.pl   |  14 +-
 src/test/subscription/t/029_on_error.pl    |   2 +-
 src/test/subscription/t/030_origin.pl      |   4 +-
 src/test/subscription/t/035_conflicts.pl   |  42 ++--
 8 files changed, 192 insertions(+), 152 deletions(-)

diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 68d6efe5114..5028fe9af09 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -2121,13 +2121,12 @@ Publications:
    The log format for logical replication conflicts is as follows:
 <synopsis>
 LOG:  conflict detected on relation "<replaceable>schemaname</replaceable>.<replaceable>tablename</replaceable>": conflict=<replaceable>conflict_type</replaceable>
-DETAIL:  <replaceable class="parameter">detailed_explanation</replaceable>.
-{<replaceable class="parameter">detail_values</replaceable> [; ... ]}.
+DETAIL:  <replaceable class="parameter">detailed_explanation</replaceable>[: <replaceable class="parameter">detail_values</replaceable> [, ... ]].
 
 <phrase>where <replaceable class="parameter">detail_values</replaceable> is one of:</phrase>
 
-    <literal>Key</literal> (<replaceable>column_name</replaceable> <optional>, ...</optional>)=(<replaceable>column_value</replaceable> <optional>, ...</optional>)
-    <literal>existing local row</literal> <optional>(<replaceable>column_name</replaceable> <optional>, ...</optional>)=</optional>(<replaceable>column_value</replaceable> <optional>, ...</optional>)
+    <literal>key</literal> (<replaceable>column_name</replaceable> <optional>, ...</optional>)=(<replaceable>column_value</replaceable> <optional>, ...</optional>)
+    <literal>local row</literal> <optional>(<replaceable>column_name</replaceable> <optional>, ...</optional>)=</optional>(<replaceable>column_value</replaceable> <optional>, ...</optional>)
     <literal>remote row</literal> <optional>(<replaceable>column_name</replaceable> <optional>, ...</optional>)=</optional>(<replaceable>column_value</replaceable> <optional>, ...</optional>)
     <literal>replica identity</literal> {(<replaceable>column_name</replaceable> <optional>, ...</optional>)=(<replaceable>column_value</replaceable> <optional>, ...</optional>) | full <optional>(<replaceable>column_name</replaceable> <optional>, ...</optional>)=</optional>(<replaceable>column_value</replaceable> <optional>, ...</optional>)}
 </synopsis>
@@ -2162,12 +2161,12 @@ DETAIL:  <replaceable class="parameter">detailed_explanation</replaceable>.
         <para>
          <replaceable class="parameter">detailed_explanation</replaceable> includes
          the origin, transaction ID, and commit timestamp of the transaction that
-         modified the existing local row, if available.
+         modified the local row, if available.
         </para>
        </listitem>
        <listitem>
         <para>
-         The <literal>Key</literal> section includes the key values of the local
+         The <literal>key</literal> section includes the key values of the local
          row that violated a unique constraint for
          <literal>insert_exists</literal>, <literal>update_exists</literal> or
          <literal>multiple_unique_conflicts</literal> conflicts.
@@ -2175,8 +2174,8 @@ DETAIL:  <replaceable class="parameter">detailed_explanation</replaceable>.
        </listitem>
        <listitem>
         <para>
-         The <literal>existing local row</literal> section includes the local
-         row if its origin differs from the remote row for
+         The <literal>local row</literal> section includes the local row if its
+         origin differs from the remote row for
          <literal>update_origin_differs</literal> or <literal>delete_origin_differs</literal>
          conflicts, or if the key value conflicts with the remote row for
          <literal>insert_exists</literal>, <literal>update_exists</literal> or
@@ -2203,8 +2202,8 @@ DETAIL:  <replaceable class="parameter">detailed_explanation</replaceable>.
        <listitem>
         <para>
          <replaceable class="parameter">column_name</replaceable> is the column name.
-         For <literal>existing local row</literal>, <literal>remote row</literal>,
-         and <literal>replica identity full</literal> cases, column names are
+         For <literal>local row</literal>, <literal>remote row</literal>, and
+         <literal>replica identity full</literal> cases, column names are
          logged only if the user lacks the privilege to access all columns of
          the table. If column names are present, they appear in the same order
          as the corresponding column values.
@@ -2259,8 +2258,8 @@ DETAIL:  <replaceable class="parameter">detailed_explanation</replaceable>.
    emit the following kind of message to the subscriber's server log:
 <screen>
 ERROR:  conflict detected on relation "public.test": conflict=insert_exists
-DETAIL:  Key already exists in unique index "t_pkey", which was modified locally in transaction 740 at 2024-06-26 10:47:04.727375+08.
-Key (c)=(1); existing local row (1, 'local'); remote row (1, 'remote').
+DETAIL:  Could not apply remote change: remote row (1, 'remote').
+Key already exists in unique index "test_pkey", modified locally in transaction 800 at 2026-01-16 18:15:25.652759+09: key (c)=(1), local row (1, 'local').
 CONTEXT:  processing remote data for replication origin "pg_16395" during "INSERT" for replication target relation "public.test" in transaction 725 finished at 0/014C0378
 </screen>
    The LSN of the transaction that contains the change violating the constraint and
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 93222ee3b88..c38ea6d1f1d 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -44,12 +44,12 @@ static void errdetail_apply_conflict(EState *estate,
 									 Oid indexoid, TransactionId localxmin,
 									 RepOriginId localorigin,
 									 TimestampTz localts, StringInfo err_msg);
-static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
-									   ConflictType type,
-									   TupleTableSlot *searchslot,
-									   TupleTableSlot *localslot,
-									   TupleTableSlot *remoteslot,
-									   Oid indexoid);
+static void get_tuple_desc(EState *estate, ResultRelInfo *relinfo,
+						   ConflictType type, char **key_desc,
+						   TupleTableSlot *searchslot, char **search_desc,
+						   TupleTableSlot *localslot, char **local_desc,
+						   TupleTableSlot *remoteslot, char **remote_desc,
+						   Oid indexoid);
 static char *build_index_value_desc(EState *estate, Relation localrel,
 									TupleTableSlot *slot, Oid indexoid);
 
@@ -186,14 +186,66 @@ errcode_apply_conflict(ConflictType type)
 	return 0;					/* silence compiler warning */
 }
 
+/*
+ * Helper function to build the additional details for conflicting key,
+ * local row, remote row, and replica identity columns.
+ */
+static void
+append_tuple_value_detail(StringInfo buf, List *tuple_values,
+						  bool need_newline)
+{
+	bool		first = true;
+
+	Assert(buf != NULL && tuple_values != NIL);
+
+	foreach_ptr(char, tuple_value, tuple_values)
+	{
+		/*
+		 * Skip if the value is NULL. This means the current user does not
+		 * have enough permissions to see all columns in the table. See
+		 * get_tuple_desc().
+		 */
+		if (!tuple_value)
+			continue;
+
+		if (first)
+		{
+			/*
+			 * translator: The colon is used as a separator in conflict
+			 * messages. The first part, built in the caller, describes what
+			 * happened locally; the second part lists the conflicting keys
+			 * and tuple data.
+			 */
+			appendStringInfoString(buf, _(": "));
+		}
+		else
+		{
+			/*
+			 * translator: This is a separator in a list of conflicting keys
+			 * and tuple data.
+			 */
+			appendStringInfoString(buf, _(", "));
+		}
+
+		appendStringInfoString(buf, tuple_value);
+		first = false;
+	}
+
+	/* translator: This is the terminator of a conflict message */
+	appendStringInfoString(buf, _("."));
+
+	if (need_newline)
+		appendStringInfoChar(buf, '\n');
+}
+
 /*
  * Add an errdetail() line showing conflict detail.
  *
  * The DETAIL line comprises of two parts:
  * 1. Explanation of the conflict type, including the origin and commit
- *    timestamp of the existing local row.
- * 2. Display of conflicting key, existing local row, remote new row, and
- *    replica identity columns, if any. The remote old row is excluded as its
+ *    timestamp of the local row.
+ * 2. Display of conflicting key, local row, remote new row, and replica
+ *    identity columns, if any. The remote old row is excluded as its
  *    information is covered in the replica identity columns.
  */
 static void
@@ -205,12 +257,22 @@ errdetail_apply_conflict(EState *estate, ResultRelInfo *relinfo,
 						 StringInfo err_msg)
 {
 	StringInfoData err_detail;
-	char	   *val_desc;
 	char	   *origin_name;
+	char	   *key_desc = NULL;
+	char	   *local_desc = NULL;
+	char	   *remote_desc = NULL;
+	char	   *search_desc = NULL;
+
+	/* Get key, replica identity, remote, and local value data */
+	get_tuple_desc(estate, relinfo, type, &key_desc,
+				   localslot, &local_desc,
+				   remoteslot, &remote_desc,
+				   searchslot, &search_desc,
+				   indexoid);
 
 	initStringInfo(&err_detail);
 
-	/* First, construct a detailed message describing the type of conflict */
+	/* Construct a detailed message describing the type of conflict */
 	switch (type)
 	{
 		case CT_INSERT_EXISTS:
@@ -219,14 +281,23 @@ errdetail_apply_conflict(EState *estate, ResultRelInfo *relinfo,
 			Assert(OidIsValid(indexoid) &&
 				   CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
 
+			if (err_msg->len == 0)
+			{
+				appendStringInfoString(&err_detail, _("Could not apply remote change"));
+
+				append_tuple_value_detail(&err_detail,
+										  list_make2(remote_desc, search_desc),
+										  true);
+			}
+
 			if (localts)
 			{
 				if (localorigin == InvalidRepOriginId)
-					appendStringInfo(&err_detail, _("Key already exists in unique index \"%s\", modified locally in transaction %u at %s."),
+					appendStringInfo(&err_detail, _("Key already exists in unique index \"%s\", modified locally in transaction %u at %s"),
 									 get_rel_name(indexoid),
 									 localxmin, timestamptz_to_str(localts));
 				else if (replorigin_by_oid(localorigin, true, &origin_name))
-					appendStringInfo(&err_detail, _("Key already exists in unique index \"%s\", modified by origin \"%s\" in transaction %u at %s."),
+					appendStringInfo(&err_detail, _("Key already exists in unique index \"%s\", modified by origin \"%s\" in transaction %u at %s"),
 									 get_rel_name(indexoid), origin_name,
 									 localxmin, timestamptz_to_str(localts));
 
@@ -238,87 +309,103 @@ errdetail_apply_conflict(EState *estate, ResultRelInfo *relinfo,
 				 * manually dropped by the user.
 				 */
 				else
-					appendStringInfo(&err_detail, _("Key already exists in unique index \"%s\", modified by a non-existent origin in transaction %u at %s."),
+					appendStringInfo(&err_detail, _("Key already exists in unique index \"%s\", modified by a non-existent origin in transaction %u at %s"),
 									 get_rel_name(indexoid),
 									 localxmin, timestamptz_to_str(localts));
 			}
 			else
-				appendStringInfo(&err_detail, _("Key already exists in unique index \"%s\", modified in transaction %u."),
+				appendStringInfo(&err_detail, _("Key already exists in unique index \"%s\", modified in transaction %u"),
 								 get_rel_name(indexoid), localxmin);
 
+			append_tuple_value_detail(&err_detail,
+									  list_make2(key_desc, local_desc), false);
+
 			break;
 
 		case CT_UPDATE_ORIGIN_DIFFERS:
 			if (localorigin == InvalidRepOriginId)
-				appendStringInfo(&err_detail, _("Updating the row that was modified locally in transaction %u at %s."),
+				appendStringInfo(&err_detail, _("Updating the row that was modified locally in transaction %u at %s"),
 								 localxmin, timestamptz_to_str(localts));
 			else if (replorigin_by_oid(localorigin, true, &origin_name))
-				appendStringInfo(&err_detail, _("Updating the row that was modified by a different origin \"%s\" in transaction %u at %s."),
+				appendStringInfo(&err_detail, _("Updating the row that was modified by a different origin \"%s\" in transaction %u at %s"),
 								 origin_name, localxmin, timestamptz_to_str(localts));
 
 			/* The origin that modified this row has been removed. */
 			else
-				appendStringInfo(&err_detail, _("Updating the row that was modified by a non-existent origin in transaction %u at %s."),
+				appendStringInfo(&err_detail, _("Updating the row that was modified by a non-existent origin in transaction %u at %s"),
 								 localxmin, timestamptz_to_str(localts));
 
+			append_tuple_value_detail(&err_detail,
+									  list_make3(local_desc, remote_desc,
+												 search_desc), false);
+
 			break;
 
 		case CT_UPDATE_DELETED:
+			appendStringInfoString(&err_detail, _("Could not find the row to be updated"));
+
+			append_tuple_value_detail(&err_detail,
+									  list_make2(remote_desc, search_desc),
+									  true);
+
 			if (localts)
 			{
 				if (localorigin == InvalidRepOriginId)
-					appendStringInfo(&err_detail, _("The row to be updated was deleted locally in transaction %u at %s."),
+					appendStringInfo(&err_detail, _("The row to be updated was deleted locally in transaction %u at %s"),
 									 localxmin, timestamptz_to_str(localts));
 				else if (replorigin_by_oid(localorigin, true, &origin_name))
-					appendStringInfo(&err_detail, _("The row to be updated was deleted by a different origin \"%s\" in transaction %u at %s."),
+					appendStringInfo(&err_detail, _("The row to be updated was deleted by a different origin \"%s\" in transaction %u at %s"),
 									 origin_name, localxmin, timestamptz_to_str(localts));
 
 				/* The origin that modified this row has been removed. */
 				else
-					appendStringInfo(&err_detail, _("The row to be updated was deleted by a non-existent origin in transaction %u at %s."),
+					appendStringInfo(&err_detail, _("The row to be updated was deleted by a non-existent origin in transaction %u at %s"),
 									 localxmin, timestamptz_to_str(localts));
 			}
 			else
-				appendStringInfo(&err_detail, _("The row to be updated was deleted."));
+				appendStringInfo(&err_detail, _("The row to be updated was deleted"));
 
 			break;
 
 		case CT_UPDATE_MISSING:
-			appendStringInfoString(&err_detail, _("Could not find the row to be updated."));
+			appendStringInfoString(&err_detail, _("Could not find the row to be updated"));
+
+			append_tuple_value_detail(&err_detail,
+									  list_make2(remote_desc, search_desc),
+									  false);
+
 			break;
 
 		case CT_DELETE_ORIGIN_DIFFERS:
 			if (localorigin == InvalidRepOriginId)
-				appendStringInfo(&err_detail, _("Deleting the row that was modified locally in transaction %u at %s."),
+				appendStringInfo(&err_detail, _("Deleting the row that was modified locally in transaction %u at %s"),
 								 localxmin, timestamptz_to_str(localts));
 			else if (replorigin_by_oid(localorigin, true, &origin_name))
-				appendStringInfo(&err_detail, _("Deleting the row that was modified by a different origin \"%s\" in transaction %u at %s."),
+				appendStringInfo(&err_detail, _("Deleting the row that was modified by a different origin \"%s\" in transaction %u at %s"),
 								 origin_name, localxmin, timestamptz_to_str(localts));
 
 			/* The origin that modified this row has been removed. */
 			else
-				appendStringInfo(&err_detail, _("Deleting the row that was modified by a non-existent origin in transaction %u at %s."),
+				appendStringInfo(&err_detail, _("Deleting the row that was modified by a non-existent origin in transaction %u at %s"),
 								 localxmin, timestamptz_to_str(localts));
 
+			append_tuple_value_detail(&err_detail,
+									  list_make3(local_desc, remote_desc,
+												 search_desc), false);
+
 			break;
 
 		case CT_DELETE_MISSING:
-			appendStringInfoString(&err_detail, _("Could not find the row to be deleted."));
+			appendStringInfoString(&err_detail, _("Could not find the row to be deleted"));
+
+			append_tuple_value_detail(&err_detail,
+									  list_make1(search_desc), false);
+
 			break;
 	}
 
 	Assert(err_detail.len > 0);
 
-	val_desc = build_tuple_value_details(estate, relinfo, type, searchslot,
-										 localslot, remoteslot, indexoid);
-
-	/*
-	 * Next, append the key values, existing local row, remote row, and
-	 * replica identity columns after the message.
-	 */
-	if (val_desc)
-		appendStringInfo(&err_detail, "\n%s", val_desc);
-
 	/*
 	 * Insert a blank line to visually separate the new detail line from the
 	 * existing ones.
@@ -330,29 +417,27 @@ errdetail_apply_conflict(EState *estate, ResultRelInfo *relinfo,
 }
 
 /*
- * Helper function to build the additional details for conflicting key,
- * existing local row, remote row, and replica identity columns.
+ * Extract conflicting key, local row, remote row, and replica identity
+ * columns. Results are set at xxx_desc.
  *
- * If the return value is NULL, it indicates that the current user lacks
- * permissions to view the columns involved.
+ * If the output is NULL, it indicates that the current user lacks permissions
+ * to view the columns involved.
  */
-static char *
-build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
-						  ConflictType type,
-						  TupleTableSlot *searchslot,
-						  TupleTableSlot *localslot,
-						  TupleTableSlot *remoteslot,
-						  Oid indexoid)
+static void
+get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
+			   char **key_desc,
+			   TupleTableSlot *localslot, char **local_desc,
+			   TupleTableSlot *remoteslot, char **remote_desc,
+			   TupleTableSlot *searchslot, char **search_desc,
+			   Oid indexoid)
 {
 	Relation	localrel = relinfo->ri_RelationDesc;
 	Oid			relid = RelationGetRelid(localrel);
 	TupleDesc	tupdesc = RelationGetDescr(localrel);
-	StringInfoData tuple_value;
 	char	   *desc = NULL;
 
-	Assert(searchslot || localslot || remoteslot);
-
-	initStringInfo(&tuple_value);
+	Assert((localslot && local_desc) || (remoteslot && remote_desc) ||
+		   (searchslot && search_desc));
 
 	/*
 	 * Report the conflicting key values in the case of a unique constraint
@@ -363,35 +448,24 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
 	{
 		Assert(OidIsValid(indexoid) && localslot);
 
-		desc = build_index_value_desc(estate, localrel, localslot, indexoid);
+		desc = build_index_value_desc(estate, localrel, localslot,
+									  indexoid);
 
 		if (desc)
-			appendStringInfo(&tuple_value, _("Key %s"), desc);
+			*key_desc = psprintf(_("key %s"), desc);
 	}
 
 	if (localslot)
 	{
 		/*
 		 * The 'modifiedCols' only applies to the new tuple, hence we pass
-		 * NULL for the existing local row.
+		 * NULL for the local row.
 		 */
 		desc = ExecBuildSlotValueDescription(relid, localslot, tupdesc,
 											 NULL, 64);
 
 		if (desc)
-		{
-			if (tuple_value.len > 0)
-			{
-				appendStringInfoString(&tuple_value, "; ");
-				appendStringInfo(&tuple_value, _("existing local row %s"),
-								 desc);
-			}
-			else
-			{
-				appendStringInfo(&tuple_value, _("Existing local row %s"),
-								 desc);
-			}
-		}
+			*local_desc = psprintf(_("local row %s"), desc);
 	}
 
 	if (remoteslot)
@@ -407,21 +481,12 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
 		 */
 		modifiedCols = bms_union(ExecGetInsertedCols(relinfo, estate),
 								 ExecGetUpdatedCols(relinfo, estate));
-		desc = ExecBuildSlotValueDescription(relid, remoteslot, tupdesc,
-											 modifiedCols, 64);
+		desc = ExecBuildSlotValueDescription(relid, remoteslot,
+											 tupdesc, modifiedCols,
+											 64);
 
 		if (desc)
-		{
-			if (tuple_value.len > 0)
-			{
-				appendStringInfoString(&tuple_value, "; ");
-				appendStringInfo(&tuple_value, _("remote row %s"), desc);
-			}
-			else
-			{
-				appendStringInfo(&tuple_value, _("Remote row %s"), desc);
-			}
-		}
+			*remote_desc = psprintf(_("remote row %s"), desc);
 	}
 
 	if (searchslot)
@@ -448,28 +513,10 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
 			desc = ExecBuildSlotValueDescription(relid, searchslot, tupdesc, NULL, 64);
 
 		if (desc)
-		{
-			if (tuple_value.len > 0)
-			{
-				appendStringInfoString(&tuple_value, "; ");
-				appendStringInfo(&tuple_value, OidIsValid(replica_index)
-								 ? _("replica identity %s")
-								 : _("replica identity full %s"), desc);
-			}
-			else
-			{
-				appendStringInfo(&tuple_value, OidIsValid(replica_index)
-								 ? _("Replica identity %s")
-								 : _("Replica identity full %s"), desc);
-			}
-		}
+			*search_desc = psprintf(OidIsValid(replica_index)
+									? _("replica identity %s")
+									: _("replica identity full %s"), desc);
 	}
-
-	if (tuple_value.len == 0)
-		return NULL;
-
-	appendStringInfoChar(&tuple_value, '.');
-	return tuple_value.data;
 }
 
 /*
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index d538274637f..2de7b624eb2 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -64,7 +64,7 @@ typedef enum
 #define CONFLICT_NUM_TYPES (CT_MULTIPLE_UNIQUE_CONFLICTS + 1)
 
 /*
- * Information for the existing local row that caused the conflict.
+ * Information for the local row that caused the conflict.
  */
 typedef struct ConflictTupleInfo
 {
diff --git a/src/test/subscription/t/001_rep_changes.pl b/src/test/subscription/t/001_rep_changes.pl
index 58e4b2398ff..d7e62e4d488 100644
--- a/src/test/subscription/t/001_rep_changes.pl
+++ b/src/test/subscription/t/001_rep_changes.pl
@@ -366,15 +366,15 @@ $node_publisher->wait_for_catchup('tap_sub');
 my $logfile = slurp_file($node_subscriber->logfile, $log_location);
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab_full_pk": conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be updated.*\n.*Remote row \(1, quux\); replica identity \(a\)=\(1\)/m,
+	qr/conflict detected on relation "public.tab_full_pk": conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be updated: remote row \(1, quux\), replica identity \(a\)=\(1\)/m,
 	'update target row is missing');
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab_full": conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be updated.*\n.*Remote row \(26\); replica identity full \(25\)/m,
+	qr/conflict detected on relation "public.tab_full": conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be updated: remote row \(26\), replica identity full \(25\)/m,
 	'update target row is missing');
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab_full_pk": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted.*\n.*Replica identity \(a\)=\(2\)/m,
+	qr/conflict detected on relation "public.tab_full_pk": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted: replica identity \(a\)=\(2\)/m,
 	'delete target row is missing');
 
 $node_subscriber->append_conf('postgresql.conf',
diff --git a/src/test/subscription/t/013_partition.pl b/src/test/subscription/t/013_partition.pl
index 4f90bc9a62a..234d4f003b7 100644
--- a/src/test/subscription/t/013_partition.pl
+++ b/src/test/subscription/t/013_partition.pl
@@ -369,19 +369,19 @@ $node_publisher->wait_for_catchup('sub2');
 my $logfile = slurp_file($node_subscriber1->logfile(), $log_location);
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab1_2_2": conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be updated.*\n.*Remote row \(null, 4, quux\); replica identity \(a\)=\(4\)/,
+	qr/conflict detected on relation "public.tab1_2_2": conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be updated: remote row \(null, 4, quux\), replica identity \(a\)=\(4\)/,
 	'update target row is missing in tab1_2_2');
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab1_1": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted.*\n.*Replica identity \(a\)=\(1\)/,
+	qr/conflict detected on relation "public.tab1_1": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted: replica identity \(a\)=\(1\)/,
 	'delete target row is missing in tab1_1');
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab1_2_2": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted.*\n.*Replica identity \(a\)=\(4\)/,
+	qr/conflict detected on relation "public.tab1_2_2": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted: replica identity \(a\)=\(4\)/,
 	'delete target row is missing in tab1_2_2');
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab1_def": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted.*\n.*Replica identity \(a\)=\(10\)/,
+	qr/conflict detected on relation "public.tab1_def": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted: replica identity \(a\)=\(10\)/,
 	'delete target row is missing in tab1_def');
 
 # Tests for replication using root table identity and schema
@@ -786,11 +786,11 @@ $node_publisher->wait_for_catchup('sub2');
 $logfile = slurp_file($node_subscriber1->logfile(), $log_location);
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab2_1": conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be updated.*\n.*Remote row \(pub_tab2, quux, 5\); replica identity \(a\)=\(5\)/,
+	qr/conflict detected on relation "public.tab2_1": conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be updated: remote row \(pub_tab2, quux, 5\), replica identity \(a\)=\(5\)/,
 	'update target row is missing in tab2_1');
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab2_1": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted.*\n.*Replica identity \(a\)=\(1\)/,
+	qr/conflict detected on relation "public.tab2_1": conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be deleted: replica identity \(a\)=\(1\)/,
 	'delete target row is missing in tab2_1');
 
 # Enable the track_commit_timestamp to detect the conflict when attempting
@@ -809,7 +809,7 @@ $node_publisher->wait_for_catchup('sub_viaroot');
 $logfile = slurp_file($node_subscriber1->logfile(), $log_location);
 like(
 	$logfile,
-	qr/conflict detected on relation "public.tab2_1": conflict=update_origin_differs.*\n.*DETAIL:.* Updating the row that was modified locally in transaction [0-9]+ at .*\n.*Existing local row \(yyy, null, 3\); remote row \(pub_tab2, quux, 3\); replica identity \(a\)=\(3\)/,
+	qr/conflict detected on relation "public.tab2_1": conflict=update_origin_differs.*\n.*DETAIL:.* Updating the row that was modified locally in transaction [0-9]+ at .*: local row \(yyy, null, 3\), remote row \(pub_tab2, quux, 3\), replica identity \(a\)=\(3\)./,
 	'updating a row that was modified by a different origin');
 
 # The remaining tests no longer test conflict detection.
diff --git a/src/test/subscription/t/029_on_error.pl b/src/test/subscription/t/029_on_error.pl
index 79271be684d..7d68759b6cd 100644
--- a/src/test/subscription/t/029_on_error.pl
+++ b/src/test/subscription/t/029_on_error.pl
@@ -30,7 +30,7 @@ sub test_skip_lsn
 	# ERROR with its CONTEXT when retrieving this information.
 	my $contents = slurp_file($node_subscriber->logfile, $offset);
 	$contents =~
-	  qr/conflict detected on relation "public.tbl".*\n.*DETAIL:.* Key already exists in unique index "tbl_pkey", modified by .*origin.* transaction \d+ at .*\n.*Key \(i\)=\(\d+\); existing local row .*; remote row .*\n.*CONTEXT:.* for replication target relation "public.tbl" in transaction \d+, finished at ([[:xdigit:]]+\/[[:xdigit:]]+)/m
+	  qr/conflict detected on relation "public.tbl".*\n.*DETAIL:.* Could not apply remote change.*\n.*Key already exists in unique index "tbl_pkey", modified by .*origin.* in transaction \d+ at .*: key .*, local row .*\n.*CONTEXT:.* for replication target relation "public.tbl" in transaction \d+, finished at ([[:xdigit:]]+\/[[:xdigit:]]+)/m
 	  or die "could not get error-LSN";
 	my $lsn = $1;
 
diff --git a/src/test/subscription/t/030_origin.pl b/src/test/subscription/t/030_origin.pl
index f2ab30f5809..5076ebe609b 100644
--- a/src/test/subscription/t/030_origin.pl
+++ b/src/test/subscription/t/030_origin.pl
@@ -163,7 +163,7 @@ is($result, qq(32), 'The node_A data replicated to node_B');
 $node_C->safe_psql('postgres', "UPDATE tab SET a = 33 WHERE a = 32;");
 
 $node_B->wait_for_log(
-	qr/conflict detected on relation "public.tab": conflict=update_origin_differs.*\n.*DETAIL:.* Updating the row that was modified by a different origin ".*" in transaction [0-9]+ at .*\n.*Existing local row \(32\); remote row \(33\); replica identity \(a\)=\(32\)/
+	qr/conflict detected on relation "public.tab": conflict=update_origin_differs.*\n.*DETAIL:.* Updating the row that was modified by a different origin ".*" in transaction [0-9]+ at .*: local row \(32\), remote row \(33\), replica identity \(a\)=\(32\)./
 );
 
 $node_B->safe_psql('postgres', "DELETE FROM tab;");
@@ -179,7 +179,7 @@ is($result, qq(33), 'The node_A data replicated to node_B');
 $node_C->safe_psql('postgres', "DELETE FROM tab WHERE a = 33;");
 
 $node_B->wait_for_log(
-	qr/conflict detected on relation "public.tab": conflict=delete_origin_differs.*\n.*DETAIL:.* Deleting the row that was modified by a different origin ".*" in transaction [0-9]+ at .*\n.*Existing local row \(33\); replica identity \(a\)=\(33\)/
+	qr/conflict detected on relation "public.tab": conflict=delete_origin_differs.*\n.*DETAIL:.* Deleting the row that was modified by a different origin ".*" in transaction [0-9]+ at .*: local row \(33\), replica identity \(a\)=\(33\).*/
 );
 
 # The remaining tests no longer test conflict detection.
diff --git a/src/test/subscription/t/035_conflicts.pl b/src/test/subscription/t/035_conflicts.pl
index ddc75e23fb0..426ad74cf33 100644
--- a/src/test/subscription/t/035_conflicts.pl
+++ b/src/test/subscription/t/035_conflicts.pl
@@ -78,12 +78,10 @@ $node_publisher->safe_psql('postgres',
 # Confirm that this causes an error on the subscriber
 $node_subscriber->wait_for_log(
 	qr/conflict detected on relation \"public.conf_tab\": conflict=multiple_unique_conflicts.*
-.*Key already exists in unique index \"conf_tab_pkey\".*
-.*Key \(a\)=\(2\); existing local row \(2, 2, 2\); remote row \(2, 3, 4\).*
-.*Key already exists in unique index \"conf_tab_b_key\".*
-.*Key \(b\)=\(3\); existing local row \(3, 3, 3\); remote row \(2, 3, 4\).*
-.*Key already exists in unique index \"conf_tab_c_key\".*
-.*Key \(c\)=\(4\); existing local row \(4, 4, 4\); remote row \(2, 3, 4\)./,
+.*Could not apply remote change: remote row \(2, 3, 4\).*
+.*Key already exists in unique index \"conf_tab_pkey\", modified in transaction .*: key \(a\)=\(2\), local row \(2, 2, 2\).*
+.*Key already exists in unique index \"conf_tab_b_key\", modified in transaction .*: key \(b\)=\(3\), local row \(3, 3, 3\).*
+.*Key already exists in unique index \"conf_tab_c_key\", modified in transaction .*: key \(c\)=\(4\), local row \(4, 4, 4\)./,
 	$log_offset);
 
 pass('multiple_unique_conflicts detected during insert');
@@ -110,12 +108,10 @@ $node_publisher->safe_psql('postgres',
 # Confirm that this causes an error on the subscriber
 $node_subscriber->wait_for_log(
 	qr/conflict detected on relation \"public.conf_tab\": conflict=multiple_unique_conflicts.*
-.*Key already exists in unique index \"conf_tab_pkey\".*
-.*Key \(a\)=\(6\); existing local row \(6, 6, 6\); remote row \(6, 7, 8\).*
-.*Key already exists in unique index \"conf_tab_b_key\".*
-.*Key \(b\)=\(7\); existing local row \(7, 7, 7\); remote row \(6, 7, 8\).*
-.*Key already exists in unique index \"conf_tab_c_key\".*
-.*Key \(c\)=\(8\); existing local row \(8, 8, 8\); remote row \(6, 7, 8\)./,
+.*Could not apply remote change: remote row \(6, 7, 8\), replica identity \(a\)=\(5\).*
+.*Key already exists in unique index \"conf_tab_pkey\", modified in transaction .*: key \(a\)=\(6\), local row \(6, 6, 6\).*
+.*Key already exists in unique index \"conf_tab_b_key\", modified in transaction .*: key \(b\)=\(7\), local row \(7, 7, 7\).*
+.*Key already exists in unique index \"conf_tab_c_key\", modified in transaction .*: key \(c\)=\(8\), local row \(8, 8, 8\)./,
 	$log_offset);
 
 pass('multiple_unique_conflicts detected during update');
@@ -138,10 +134,9 @@ $node_publisher->safe_psql('postgres',
 
 $node_subscriber->wait_for_log(
 	qr/conflict detected on relation \"public.conf_tab_2_p1\": conflict=multiple_unique_conflicts.*
-.*Key already exists in unique index \"conf_tab_2_p1_pkey\".*
-.*Key \(a\)=\(55\); existing local row \(55, 2, 3\); remote row \(55, 2, 3\).*
-.*Key already exists in unique index \"conf_tab_2_p1_a_b_key\".*
-.*Key \(a, b\)=\(55, 2\); existing local row \(55, 2, 3\); remote row \(55, 2, 3\)./,
+.*Could not apply remote change: remote row \(55, 2, 3\).*
+.*Key already exists in unique index \"conf_tab_2_p1_pkey\", modified in transaction .*: key \(a\)=\(55\), local row \(55, 2, 3\).*
+.*Key already exists in unique index \"conf_tab_2_p1_a_b_key\", modified in transaction .*: key \(a, b\)=\(55, 2\), local row \(55, 2, 3\)./,
 	$log_offset);
 
 pass('multiple_unique_conflicts detected on a leaf partition during insert');
@@ -319,8 +314,7 @@ my $logfile = slurp_file($node_B->logfile(), $log_location);
 like(
 	$logfile,
 	qr/conflict detected on relation "public.tab": conflict=delete_origin_differs.*
-.*DETAIL:.* Deleting the row that was modified locally in transaction [0-9]+ at .*
-.*Existing local row \(1, 3\); replica identity \(a\)=\(1\)/,
+.*DETAIL:.* Deleting the row that was modified locally in transaction [0-9]+ at .*: local row \(1, 3\), replica identity \(a\)=\(1\)./,
 	'delete target row was modified in tab');
 
 $log_location = -s $node_A->logfile;
@@ -333,8 +327,8 @@ $logfile = slurp_file($node_A->logfile(), $log_location);
 like(
 	$logfile,
 	qr/conflict detected on relation "public.tab": conflict=update_deleted.*
-.*DETAIL:.* The row to be updated was deleted locally in transaction [0-9]+ at .*
-.*Remote row \(1, 3\); replica identity \(a\)=\(1\)/,
+.*DETAIL:.* Could not find the row to be updated: remote row \(1, 3\), replica identity \(a\)=\(1\).
+.*The row to be updated was deleted locally in transaction [0-9]+ at .*/,
 	'update target row was deleted in tab');
 
 # Remember the next transaction ID to be assigned
@@ -381,8 +375,8 @@ $logfile = slurp_file($node_A->logfile(), $log_location);
 like(
 	$logfile,
 	qr/conflict detected on relation "public.tab": conflict=update_deleted.*
-.*DETAIL:.* The row to be updated was deleted locally in transaction [0-9]+ at .*
-.*Remote row \(2, 4\); replica identity full \(2, 2\)/,
+.*DETAIL:.* Could not find the row to be updated: remote row \(2, 4\), replica identity full \(2, 2\).*
+.*The row to be updated was deleted locally in transaction [0-9]+ at .*/,
 	'update target row was deleted in tab');
 
 ###############################################################################
@@ -540,8 +534,8 @@ if ($injection_points_supported != 0)
 	like(
 		$logfile,
 		qr/conflict detected on relation "public.tab": conflict=update_deleted.*
-.*DETAIL:.* The row to be updated was deleted locally in transaction [0-9]+ at .*
-.*Remote row \(1, 2\); replica identity full \(1, 1\)/,
+.*DETAIL:.* Could not find the row to be updated: remote row \(1, 2\), replica identity full \(1, 1\).*
+.*The row to be updated was deleted locally in transaction [0-9]+ at .*/,
 		'update target row was deleted in tab');
 
 	# Remember the next transaction ID to be assigned
-- 
2.47.3

