From 78cc3deacfd686d843f77f3c2fdf7dca70b16c8f Mon Sep 17 00:00:00 2001
From: Nisha Moond <nisha.moond412@gmail.com>
Date: Tue, 22 Oct 2024 11:01:41 +0530
Subject: [PATCH v1] Implement the conflict detection for
 multiple_unique_conflicts in logical replication

Introduce a new conflict type, multiple_unique_conflicts, to handle cases
where an incoming row during logical replication violates multiple UNIQUE
constraints.

Previously, the apply worker detected and reported only the first
encountered key conflict (insert_exists/update_exists), causing repeated
failures as each constraint violation need to be handled one by one making
the process slow and error-prone.

Now, the apply worker checks all unique constraints upfront and reports a
multiple_unique_conflicts if multiple violations exist. This allows users
to resolve all conflicts at once by deleting all conflicting tuples rather
than dealing with them individually or skipping the transaction.

Also, the patch adds a new column in view pg_stat_subscription_stats
to support stats collection for this conflict type.
---
 doc/src/sgml/logical-replication.sgml         |  12 ++
 doc/src/sgml/monitoring.sgml                  |  12 ++
 src/backend/catalog/system_views.sql          |   1 +
 src/backend/executor/execReplication.c        |  50 ++++++--
 src/backend/replication/logical/conflict.c    |  94 +++++++++++++-
 src/backend/utils/adt/pgstatfuncs.c           |   6 +-
 src/include/catalog/pg_proc.dat               |   6 +-
 src/include/replication/conflict.h            |  11 +-
 src/test/regress/expected/rules.out           |   3 +-
 src/test/subscription/meson.build             |   1 +
 .../t/035_multiple_unique_conflicts.pl        | 119 ++++++++++++++++++
 11 files changed, 293 insertions(+), 22 deletions(-)
 create mode 100644 src/test/subscription/t/035_multiple_unique_conflicts.pl

diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 3d18e507bb..b0c8a0ff48 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -1877,6 +1877,18 @@ test_sub=# SELECT * from tab_gen_to_gen;
       </para>
      </listitem>
     </varlistentry>
+    <varlistentry id="conflict-multiple-unique-conflicts" xreflabel="multiple_unique_conflicts">
+     <term><literal>multiple_unique_conflicts</literal></term>
+     <listitem>
+      <para>
+       Inserting a row or updating values of a row violates more than one <literal>NOT DEFERRABLE</literal>
+       unique constraints. Note that to log the origin and commit timestamp details of the conflicting key,
+       <link linkend="guc-track-commit-timestamp"><varname>track_commit_timestamp</varname></link>
+       should be enabled on the subscriber. In this case, an error will be
+       raised until the conflict is resolved manually.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
     Note that there are other conflict scenarios, such as exclusion constraint
     violations. Currently, we do not provide additional details for them in the
diff --git a/doc/src/sgml/monitoring.sgml b/doc/src/sgml/monitoring.sgml
index 928a6eb64b..8ccc92fddf 100644
--- a/doc/src/sgml/monitoring.sgml
+++ b/doc/src/sgml/monitoring.sgml
@@ -2244,6 +2244,18 @@ description | Waiting for a newly initialized WAL file to reach durable storage
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>confl_multiple_unique_conflicts</structfield> <type>bigint</type>
+      </para>
+      <para>
+       Number of times a row insertion or an updated row values violated multiple
+       <literal>NOT DEFERRABLE</literal> unique constraints during the
+       application of changes. See <xref linkend="conflict-multiple-unique-conflicts"/>
+       for details about this conflict.
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>stats_reset</structfield> <type>timestamp with time zone</type>
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index eff0990957..16951272b4 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -1388,6 +1388,7 @@ CREATE VIEW pg_stat_subscription_stats AS
         ss.confl_update_missing,
         ss.confl_delete_origin_differs,
         ss.confl_delete_missing,
+        ss.confl_multiple_unique_conflicts,
         ss.stats_reset
     FROM pg_subscription as s,
          pg_stat_get_subscription_stats(s.oid) as ss;
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index 5f7613cc83..82f3664e88 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -493,25 +493,55 @@ CheckAndReportConflict(ResultRelInfo *resultRelInfo, EState *estate,
 					   ConflictType type, List *recheckIndexes,
 					   TupleTableSlot *searchslot, TupleTableSlot *remoteslot)
 {
+	int			conflicts = 0;
+	List	   *conflictSlots = NIL;
+	List	   *conflictIndexes = NIL;
+	TupleTableSlot *conflictslot;
+
 	/* Check all the unique indexes for a conflict */
 	foreach_oid(uniqueidx, resultRelInfo->ri_onConflictArbiterIndexes)
 	{
-		TupleTableSlot *conflictslot;
-
 		if (list_member_oid(recheckIndexes, uniqueidx) &&
 			FindConflictTuple(resultRelInfo, estate, uniqueidx, remoteslot,
 							  &conflictslot))
 		{
-			RepOriginId origin;
-			TimestampTz committs;
-			TransactionId xmin;
-
-			GetTupleTransactionInfo(conflictslot, &xmin, &origin, &committs);
-			ReportApplyConflict(estate, resultRelInfo, ERROR, type,
-								searchslot, conflictslot, remoteslot,
-								uniqueidx, xmin, origin, committs);
+			conflicts++;
+
+			/* Add the conflict slot and index to their respective lists */
+			conflictSlots = lappend(conflictSlots, conflictslot);
+			conflictIndexes = lappend_oid(conflictIndexes, uniqueidx);
 		}
 	}
+
+	/*
+	 * Report an UPDATE_EXISTS conflict when only one unique constraint is
+	 * violated.
+	 */
+	if (conflicts == 1)
+	{
+		Oid			uniqueidx;
+		RepOriginId origin;
+		TimestampTz committs;
+		TransactionId xmin;
+
+		uniqueidx = linitial_oid(conflictIndexes);
+		conflictslot = linitial(conflictSlots);
+
+		GetTupleTransactionInfo(conflictslot, &xmin, &origin, &committs);
+		ReportApplyConflict(estate, resultRelInfo, ERROR, type,
+							searchslot, conflictslot, remoteslot,
+							uniqueidx, xmin, origin, committs);
+	}
+
+	/*
+	 * Report a MULTIPLE_UNIQUE_CONFLICTS when two or more unique constraints
+	 * are violated.
+	 */
+	else if (conflicts > 1)
+		ReportMultipleUniqueConflict(estate, resultRelInfo, ERROR,
+									 CT_MULTIPLE_UNIQUE_CONFLICTS,
+									 searchslot, remoteslot,
+									 conflictSlots, conflictIndexes);
 }
 
 /*
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 772fc83e88..2f71fbb287 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -29,7 +29,8 @@ static const char *const ConflictTypeNames[] = {
 	[CT_UPDATE_EXISTS] = "update_exists",
 	[CT_UPDATE_MISSING] = "update_missing",
 	[CT_DELETE_ORIGIN_DIFFERS] = "delete_origin_differs",
-	[CT_DELETE_MISSING] = "delete_missing"
+	[CT_DELETE_MISSING] = "delete_missing",
+	[CT_MULTIPLE_UNIQUE_CONFLICTS] = "multiple_unique_conflicts"
 };
 
 static int	errcode_apply_conflict(ConflictType type);
@@ -41,7 +42,7 @@ static int	errdetail_apply_conflict(EState *estate,
 									 TupleTableSlot *remoteslot,
 									 Oid indexoid, TransactionId localxmin,
 									 RepOriginId localorigin,
-									 TimestampTz localts);
+									 TimestampTz localts, StringInfo err_msg);
 static char *build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
 									   ConflictType type,
 									   TupleTableSlot *searchslot,
@@ -125,7 +126,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
 				   ConflictTypeNames[type]),
 			errdetail_apply_conflict(estate, relinfo, type, searchslot,
 									 localslot, remoteslot, indexoid,
-									 localxmin, localorigin, localts));
+									 localxmin, localorigin, localts, NULL));
 }
 
 /*
@@ -169,6 +170,7 @@ errcode_apply_conflict(ConflictType type)
 	{
 		case CT_INSERT_EXISTS:
 		case CT_UPDATE_EXISTS:
+		case CT_MULTIPLE_UNIQUE_CONFLICTS:
 			return errcode(ERRCODE_UNIQUE_VIOLATION);
 		case CT_UPDATE_ORIGIN_DIFFERS:
 		case CT_UPDATE_MISSING:
@@ -196,7 +198,8 @@ errdetail_apply_conflict(EState *estate, ResultRelInfo *relinfo,
 						 ConflictType type, TupleTableSlot *searchslot,
 						 TupleTableSlot *localslot, TupleTableSlot *remoteslot,
 						 Oid indexoid, TransactionId localxmin,
-						 RepOriginId localorigin, TimestampTz localts)
+						 RepOriginId localorigin, TimestampTz localts,
+						 StringInfo err_msg)
 {
 	StringInfoData err_detail;
 	char	   *val_desc;
@@ -209,6 +212,7 @@ errdetail_apply_conflict(EState *estate, ResultRelInfo *relinfo,
 	{
 		case CT_INSERT_EXISTS:
 		case CT_UPDATE_EXISTS:
+		case CT_MULTIPLE_UNIQUE_CONFLICTS:
 			Assert(OidIsValid(indexoid));
 
 			if (localts)
@@ -291,6 +295,17 @@ errdetail_apply_conflict(EState *estate, ResultRelInfo *relinfo,
 	if (val_desc)
 		appendStringInfo(&err_detail, "\n%s", val_desc);
 
+	/*
+	 * If the caller provides a non-null 'err_msg' pointer, only the
+	 * err_detail.data is requested. Append the constructed err_detail message
+	 * to 'err_msg' and return.
+	 */
+	if (err_msg)
+	{
+		appendStringInfo(err_msg, "\n%s", err_detail.data);
+		return 0;
+	}
+
 	return errdetail_internal("%s", err_detail.data);
 }
 
@@ -323,7 +338,8 @@ build_tuple_value_details(EState *estate, ResultRelInfo *relinfo,
 	 * Report the conflicting key values in the case of a unique constraint
 	 * violation.
 	 */
-	if (type == CT_INSERT_EXISTS || type == CT_UPDATE_EXISTS)
+	if (type == CT_INSERT_EXISTS || type == CT_UPDATE_EXISTS ||
+		type == CT_MULTIPLE_UNIQUE_CONFLICTS)
 	{
 		Assert(OidIsValid(indexoid) && localslot);
 
@@ -489,3 +505,71 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
 
 	return index_value;
 }
+
+/*
+ * Report a multiple_unique_conflicts while applying replication changes.
+ *
+ * 'searchslot' holds the tuple used to search the corresponding local
+ * tuple for update or deletion.
+ *
+ * 'conflictslots_list' is a list of slots containing the local tuples
+ * that conflict with the remote tuple.
+ *
+ * 'remoteslot' contains the new tuple from the remote side, if available.
+ *
+ * The 'conflictIndexes' list stores the OIDs of the unique indexes that
+ * triggered the constraint violation. These indexes help identify the key
+ * values of the conflicting tuple.
+ *
+ * The caller must ensure that all indexes in 'conflictIndexes' are locked,
+ * allowing us to fetch and display the conflicting key values.
+ */
+void
+ReportMultipleUniqueConflict(EState *estate, ResultRelInfo *relinfo,
+							 int elevel, ConflictType type,
+							 TupleTableSlot *searchslot,
+							 TupleTableSlot *remoteslot,
+							 List *conflictSlots, List *conflictIndexes)
+{
+	int			conflictNum = 0;
+	Oid			indexoid = linitial_oid(conflictIndexes);
+	Relation	localrel = relinfo->ri_RelationDesc;
+	RepOriginId localorigin;
+	TimestampTz localts;
+	TransactionId localxmin;
+	StringInfoData err_detail;
+
+	initStringInfo(&err_detail);
+	appendStringInfo(&err_detail, _("The remote tuple violates multiple unique constraints on the local table."));
+
+	foreach_ptr(TupleTableSlot, slot, conflictSlots)
+	{
+		indexoid = lfirst_oid(list_nth_cell(conflictIndexes, conflictNum));
+
+		Assert(!OidIsValid(indexoid) ||
+			   CheckRelationOidLockedByMe(indexoid, RowExclusiveLock, true));
+
+		GetTupleTransactionInfo(slot, &localxmin, &localorigin, &localts);
+
+		/*
+		 * Build the error detail message containing the conflicting key and
+		 * tuple information. The details for each conflict will be appended
+		 * to err_detail.
+		 */
+		errdetail_apply_conflict(estate, relinfo, type, searchslot,
+								 slot, remoteslot, indexoid,
+								 localxmin, localorigin, localts, &err_detail);
+
+		conflictNum++;
+	}
+
+	pgstat_report_subscription_conflict(MySubscription->oid, type);
+
+	ereport(elevel,
+			errcode_apply_conflict(type),
+			errmsg("conflict detected on relation \"%s.%s\": conflict=%s",
+				   get_namespace_name(RelationGetNamespace(localrel)),
+				   RelationGetRelationName(localrel),
+				   ConflictTypeNames[type]),
+			errdetail_internal("%s", err_detail.data));
+}
diff --git a/src/backend/utils/adt/pgstatfuncs.c b/src/backend/utils/adt/pgstatfuncs.c
index e9096a8849..550e0e8557 100644
--- a/src/backend/utils/adt/pgstatfuncs.c
+++ b/src/backend/utils/adt/pgstatfuncs.c
@@ -2159,7 +2159,7 @@ pg_stat_get_replication_slot(PG_FUNCTION_ARGS)
 Datum
 pg_stat_get_subscription_stats(PG_FUNCTION_ARGS)
 {
-#define PG_STAT_GET_SUBSCRIPTION_STATS_COLS	10
+#define PG_STAT_GET_SUBSCRIPTION_STATS_COLS	11
 	Oid			subid = PG_GETARG_OID(0);
 	TupleDesc	tupdesc;
 	Datum		values[PG_STAT_GET_SUBSCRIPTION_STATS_COLS] = {0};
@@ -2191,7 +2191,9 @@ pg_stat_get_subscription_stats(PG_FUNCTION_ARGS)
 					   INT8OID, -1, 0);
 	TupleDescInitEntry(tupdesc, (AttrNumber) 9, "confl_delete_missing",
 					   INT8OID, -1, 0);
-	TupleDescInitEntry(tupdesc, (AttrNumber) 10, "stats_reset",
+	TupleDescInitEntry(tupdesc, (AttrNumber) 10, "confl_multiple_unique_conflicts",
+					   INT8OID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 11, "stats_reset",
 					   TIMESTAMPTZOID, -1, 0);
 	BlessTupleDesc(tupdesc);
 
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 9e803d610d..25ab24b6f6 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5625,9 +5625,9 @@
 { oid => '6231', descr => 'statistics: information about subscription stats',
   proname => 'pg_stat_get_subscription_stats', provolatile => 's',
   proparallel => 'r', prorettype => 'record', proargtypes => 'oid',
-  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,timestamptz}',
-  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o}',
-  proargnames => '{subid,subid,apply_error_count,sync_error_count,confl_insert_exists,confl_update_origin_differs,confl_update_exists,confl_update_missing,confl_delete_origin_differs,confl_delete_missing,stats_reset}',
+  proallargtypes => '{oid,oid,int8,int8,int8,int8,int8,int8,int8,int8,int8,timestamptz}',
+  proargmodes => '{i,o,o,o,o,o,o,o,o,o,o,o}',
+  proargnames => '{subid,subid,apply_error_count,sync_error_count,confl_insert_exists,confl_update_origin_differs,confl_update_exists,confl_update_missing,confl_delete_origin_differs,confl_delete_missing,confl_multiple_unique_conflicts,stats_reset}',
   prosrc => 'pg_stat_get_subscription_stats' },
 { oid => '6118', descr => 'statistics: information about subscription',
   proname => 'pg_stat_get_subscription', prorows => '10', proisstrict => 'f',
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 37454dc951..29c3c08528 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -41,6 +41,9 @@ typedef enum
 	/* The row to be deleted is missing */
 	CT_DELETE_MISSING,
 
+	/* The row to be inserted/updated violates multiple unique constraint */
+	CT_MULTIPLE_UNIQUE_CONFLICTS,
+
 	/*
 	 * Other conflicts, such as exclusion constraint violations, involve more
 	 * complex rules than simple equality checks. These conflicts are left for
@@ -48,7 +51,7 @@ typedef enum
 	 */
 } ConflictType;
 
-#define CONFLICT_NUM_TYPES (CT_DELETE_MISSING + 1)
+#define CONFLICT_NUM_TYPES (CT_MULTIPLE_UNIQUE_CONFLICTS + 1)
 
 extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
 									TransactionId *xmin,
@@ -63,4 +66,10 @@ extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
 								RepOriginId localorigin, TimestampTz localts);
 extern void InitConflictIndexes(ResultRelInfo *relInfo);
 
+extern void ReportMultipleUniqueConflict(EState *estate, ResultRelInfo *relinfo,
+										 int elevel, ConflictType type,
+										 TupleTableSlot *searchslot,
+										 TupleTableSlot *remoteslot,
+										 List *conflictslots_list,
+										 List *conflictIndexes);
 #endif
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 5baba8d39f..2c95e2d197 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -2157,9 +2157,10 @@ pg_stat_subscription_stats| SELECT ss.subid,
     ss.confl_update_missing,
     ss.confl_delete_origin_differs,
     ss.confl_delete_missing,
+    ss.confl_multiple_unique_conflicts,
     ss.stats_reset
    FROM pg_subscription s,
-    LATERAL pg_stat_get_subscription_stats(s.oid) ss(subid, apply_error_count, sync_error_count, confl_insert_exists, confl_update_origin_differs, confl_update_exists, confl_update_missing, confl_delete_origin_differs, confl_delete_missing, stats_reset);
+    LATERAL pg_stat_get_subscription_stats(s.oid) ss(subid, apply_error_count, sync_error_count, confl_insert_exists, confl_update_origin_differs, confl_update_exists, confl_update_missing, confl_delete_origin_differs, confl_delete_missing, confl_multiple_unique_conflicts, stats_reset);
 pg_stat_sys_indexes| SELECT relid,
     indexrelid,
     schemaname,
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index d40b49714f..05fcdd08f5 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -41,6 +41,7 @@ tests += {
       't/032_subscribe_use_index.pl',
       't/033_run_as_table_owner.pl',
       't/034_temporal.pl',
+      't/035_multiple_unique_conflicts.pl',
       't/100_bugs.pl',
     ],
   },
diff --git a/src/test/subscription/t/035_multiple_unique_conflicts.pl b/src/test/subscription/t/035_multiple_unique_conflicts.pl
new file mode 100644
index 0000000000..50e64c936a
--- /dev/null
+++ b/src/test/subscription/t/035_multiple_unique_conflicts.pl
@@ -0,0 +1,119 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+# Test the conflict detection of conflict type 'multiple_unique_conflicts'.
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+###############################
+# Setup
+###############################
+
+# Initialize publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->append_conf('postgresql.conf',
+	qq(max_prepared_transactions = 10));
+$node_publisher->start;
+
+# Create subscriber node with track_commit_timestamp enabled
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init;
+$node_subscriber->append_conf('postgresql.conf',
+	qq(track_commit_timestamp = on));
+$node_subscriber->start;
+
+# Create table on publisher
+$node_publisher->safe_psql('postgres',
+	"CREATE TABLE conf_tab(a int PRIMARY key, b int unique, c int unique);");
+
+# Create similar table on subscriber
+$node_subscriber->safe_psql('postgres',
+	"CREATE TABLE conf_tab(a int PRIMARY key, b int unique, c int unique);");
+
+# Setup logical replication
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION tap_pub FOR TABLE conf_tab");
+
+# Create the subscription
+my $appname = 'tap_sub';
+$node_subscriber->safe_psql(
+	'postgres',
+	"CREATE SUBSCRIPTION tap_sub
+	CONNECTION '$publisher_connstr application_name=$appname'
+	PUBLICATION tap_pub;");
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher, $appname);
+
+##################################################
+# INSERT some data on Pub and Sub
+##################################################
+
+# Insert data in the publisher
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber
+$node_subscriber->safe_psql(
+	'postgres',
+	"INSERT INTO conf_tab VALUES (2,2,2);
+     INSERT INTO conf_tab VALUES (3,3,3);
+     INSERT INTO conf_tab VALUES (4,4,4);");
+
+my $log_location = -s $node_subscriber->logfile;
+##################################################
+# Test multiple_unique_conflicts due to INSERT
+##################################################
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO conf_tab VALUES (2,3,4);");
+
+my $logfile = slurp_file($node_subscriber->logfile(), $log_location);
+my $log_offset = -s $node_subscriber->logfile;
+
+# Confirm that this causes an error on the subscriber
+$node_subscriber->wait_for_log(
+	qr/ERROR:  conflict detected on relation \"public.conf_tab\": conflict=multiple_unique_conflicts/,
+	$log_offset);
+
+ok( $logfile =~
+	  qr/conflict detected on relation \"public.conf_tab\": conflict=multiple_unique_conflicts*\n.*DETAIL:.* The remote tuple violates multiple unique constraints on the local table./,
+	'multiple_unique_conflicts detected during insertion');
+
+# Truncate table to get rid of the error
+$node_subscriber->safe_psql('postgres', "TRUNCATE conf_tab;");
+$node_publisher->safe_psql('postgres', "TRUNCATE conf_tab;");
+
+##################################################
+# Test multiple_unique_conflicts due to UPDATE
+##################################################
+
+# Insert data in the publisher
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO conf_tab VALUES (1,1,1);");
+
+# Insert data in the subscriber
+$node_subscriber->safe_psql(
+	'postgres',
+	"INSERT INTO conf_tab VALUES (2,2,2);
+     INSERT INTO conf_tab VALUES (3,3,3);
+     INSERT INTO conf_tab VALUES (4,4,4);");
+
+$node_publisher->safe_psql('postgres',
+	"UPDATE conf_tab set a=2,b=3,c=4 where a=1;");
+
+$log_offset = -s $node_subscriber->logfile;
+
+# Confirm that this causes an error on the subscriber
+$node_subscriber->wait_for_log(
+	qr/ERROR:  conflict detected on relation \"public.conf_tab\": conflict=multiple_unique_conflicts/,
+	$log_offset);
+
+ok( $logfile =~
+	  qr/conflict detected on relation \"public.conf_tab\": conflict=multiple_unique_conflicts*\n.*DETAIL:.* The remote tuple violates multiple unique constraints on the local table./,
+	'multiple_unique_conflicts detected during update');
+
+done_testing();
-- 
2.34.1

