From 87592b2189e330a0095f394b870e9e6f534763e5 Mon Sep 17 00:00:00 2001
From: Will Mortensen <will@extrahop.com>
Date: Wed, 10 Jan 2024 23:38:13 -0800
Subject: [PATCH v6 3/3] Add pg_wait_for_lockers() function

Rather than actually taking any locks on the table(s), it simply waits
for existing lockers using the existing WaitForLockersMultiple()
function in the lock manager.

Currently it's only supported on plain tables and doesn't automatically
handle inheritance/partition relationships.

See docs and tests for more detail.
---
 doc/src/sgml/func.sgml                        |  80 ++++++++
 src/backend/storage/lmgr/lock.c               |  21 ++
 src/backend/utils/adt/lockfuncs.c             | 100 ++++++++++
 src/include/catalog/pg_proc.dat               |   5 +
 src/include/storage/lock.h                    |   2 +
 .../expected/deadlock-wait-for-lockers.out    |  19 ++
 .../isolation/expected/wait-for-lockers.out   | 180 +++++++++++++++++
 src/test/isolation/isolation_schedule         |   2 +
 .../specs/deadlock-wait-for-lockers.spec      |  25 +++
 .../isolation/specs/wait-for-lockers.spec     |  78 ++++++++
 .../regress/expected/wait_for_lockers.out     | 185 ++++++++++++++++++
 src/test/regress/parallel_schedule            |   2 +-
 src/test/regress/sql/wait_for_lockers.sql     |  84 ++++++++
 13 files changed, 782 insertions(+), 1 deletion(-)
 create mode 100644 src/test/isolation/expected/deadlock-wait-for-lockers.out
 create mode 100644 src/test/isolation/expected/wait-for-lockers.out
 create mode 100644 src/test/isolation/specs/deadlock-wait-for-lockers.spec
 create mode 100644 src/test/isolation/specs/wait-for-lockers.spec
 create mode 100644 src/test/regress/expected/wait_for_lockers.out
 create mode 100644 src/test/regress/sql/wait_for_lockers.sql

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index de78d58d4b..06a2286bbe 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -28928,6 +28928,86 @@ SELECT convert_from(pg_read_binary_file('file_in_utf8.txt'), 'UTF8');
 
   </sect2>
 
+  <sect2 id="functions-table-lock">
+   <title>Table Lock Functions</title>
+
+   <indexterm>
+    <primary>Table Lock Functions</primary>
+   </indexterm>
+
+   <para>
+    The functions shown in <xref linkend="functions-table-lock-table"/> interact
+    with table locks. For details about these locks, see
+    <xref linkend="locking-tables"/>.
+   </para>
+
+   <table id="functions-table-lock-table">
+    <title>Table Lock Functions</title>
+    <tgroup cols="1">
+     <thead>
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        Function
+       </para>
+       <para>
+        Description
+       </para></entry>
+      </row>
+     </thead>
+
+     <tbody>
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_wait_for_lockers</primary>
+        </indexterm>
+        <function>pg_wait_for_lockers</function> ( <parameter>tables</parameter> <type>regclass[]</type>, <parameter>lockmode</parameter> <type>text</type>, <parameter>conflicting</parameter> <type>boolean</type> )
+        <returnvalue>void</returnvalue>
+       </para>
+       <para>
+        Waits for already-held table-level locks on the specified tables to be
+        released, and does not take any new table-level locks.
+       </para>
+       <para>
+        If <parameter>conflicting</parameter> is true, the function waits for
+        locks in modes that conflict with <parameter>lockmode</parameter>;
+        otherwise it waits for locks in <parameter>lockmode</parameter>. Note
+        that a lock mode may or may not conflict with itself.
+       </para>
+       <para>
+        <parameter>lockmode</parameter> must be a string that specifies a
+        table-level lock mode, such as <literal>ShareLock</literal>.
+       </para>
+       <para>
+        The function first builds a combined set of transactions that hold
+        matching locks on any of the tables, and then waits for the transactions
+        in the set to release those locks. The set does not include any
+        transaction that is only waiting to take a matching lock but does not
+        yet hold one, nor any transaction that only takes a matching lock after
+        the function finishes building the set. The set may or may not include a
+        transaction that only takes a matching lock while the function is
+        building the set. The set never includes the transaction that is
+        building the set, even if it holds a matching lock, because that would
+        trivially deadlock.
+       </para>
+       <para>
+        Since the function does not take any table-level locks, a table may be
+        dropped by another transaction while waiting. Once the drop commits,
+        there can be no more locks on the table to wait for.
+       </para>
+       <para>
+        To wait for locks on a table, the user must have
+        <literal>SELECT</literal>, <literal>INSERT</literal>,
+        <literal>UPDATE</literal>, <literal>DELETE</literal>, or
+        <literal>TRUNCATE</literal> privileges on the table.
+       </para></entry>
+      </row>
+     </tbody>
+    </tgroup>
+   </table>
+
+  </sect2>
+
   <sect2 id="functions-advisory-locks">
    <title>Advisory Lock Functions</title>
 
diff --git a/src/backend/storage/lmgr/lock.c b/src/backend/storage/lmgr/lock.c
index 41b35de019..cb54e39944 100644
--- a/src/backend/storage/lmgr/lock.c
+++ b/src/backend/storage/lmgr/lock.c
@@ -4062,6 +4062,27 @@ GetLockmodeName(LOCKMETHODID lockmethodid, LOCKMODE mode)
 	return LockMethods[lockmethodid]->lockModeNames[mode];
 }
 
+/*
+ * Convert the (case-insensitive) textual name of any lock mode to the LOCKMODE
+ * value
+ */
+LOCKMODE
+ParseLockmodeName(LOCKMETHODID lockmethodid, const char *mode_name)
+{
+	int	i;
+	LockMethod	lockMethodTable;
+
+	Assert(lockmethodid > 0 && lockmethodid < lengthof(LockMethods));
+	lockMethodTable = LockMethods[lockmethodid];
+	for (i = 1; i <= lockMethodTable->numLockModes; i++)
+		if (pg_strcasecmp(mode_name, lockMethodTable->lockModeNames[i]) == 0)
+			return i;
+
+	ereport(ERROR,
+			(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("invalid lock mode name %s", mode_name)));
+}
+
 #ifdef LOCK_DEBUG
 /*
  * Dump all locks in the given proc's myProcLocks lists.
diff --git a/src/backend/utils/adt/lockfuncs.c b/src/backend/utils/adt/lockfuncs.c
index 4b49f7fe3d..beec595e6b 100644
--- a/src/backend/utils/adt/lockfuncs.c
+++ b/src/backend/utils/adt/lockfuncs.c
@@ -14,12 +14,16 @@
 
 #include "access/htup_details.h"
 #include "access/xact.h"
+#include "catalog/catalog.h"
 #include "catalog/pg_type.h"
 #include "funcapi.h"
 #include "miscadmin.h"
+#include "storage/lmgr.h"
 #include "storage/predicate_internals.h"
+#include "utils/acl.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
+#include "utils/lsyscache.h"
 
 
 /*
@@ -601,6 +605,102 @@ pg_safe_snapshot_blocking_pids(PG_FUNCTION_ARGS)
 }
 
 
+/*
+ * pg_wait_for_lockers - wait for already-held table-level locks without taking
+ * any new table-level locks
+ *
+ * The semantics are primarily just those of WaitForLockersMultiple(). For now,
+ * only plain tables are supported, and there is no automatic handling of
+ * inheritance/partition relationships.
+ *
+ * Since no locks are taken, there are some inherent races. The specified tables
+ * must exist long enough for us to check their permissions, but could be
+ * dropped afterward.
+ */
+Datum
+pg_wait_for_lockers(PG_FUNCTION_ARGS) /*, bool conflicting)*/
+{
+	int			i;
+	Oid		   *reloids;
+	int			num_reloids;
+	LOCKMODE	mode;
+	List	   *locktags = NIL;
+	ArrayType  *reloids_a = PG_GETARG_ARRAYTYPE_P(0);
+	char	   *mode_str = text_to_cstring(PG_GETARG_TEXT_PP(1));
+	bool		conflicting = PG_GETARG_BOOL(2);
+
+	/* Validate the passed-in array */
+	Assert(ARR_ELEMTYPE(reloids_a) == REGCLASSOID);
+	if (array_contains_nulls(reloids_a))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("relation oid must not be null")));
+	reloids = (Oid *) ARR_DATA_PTR(reloids_a);
+	num_reloids = ArrayGetNItems(ARR_NDIM(reloids_a), ARR_DIMS(reloids_a));
+
+	/*
+	 * Determine lock mode. We only support relations, which use the default
+	 * lock method.
+	 */
+	mode = ParseLockmodeName(DEFAULT_LOCKMETHOD, mode_str);
+
+	for (i = 0; i < num_reloids; i++)
+	{
+		char		relkind;
+		AclResult	aclresult;
+		LOCKTAG	   *heaplocktag;
+		Oid			dbid;
+		Oid			reloid = reloids[i];
+		char	   *relname;
+
+		/*
+		 * Require the oid to refer to an existing table (at least transiently)
+		 * so the caller can be confident that they supplied a valid argument
+		 * and actually waited for something (even if the table ends up being
+		 * dropped while they wait)
+		 */
+		if (!OidIsValid(reloid))
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("relation oid %d is invalid", reloid)));
+
+		relkind = get_rel_relkind(reloid);
+		/* for error messages only */
+		relname = get_rel_name(reloid);
+		if (!relkind || relname == NULL)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("relation oid %d does not exist", reloid)));
+
+		/* Currently, we only allow plain tables */
+		if (relkind != RELKIND_RELATION && relkind != RELKIND_PARTITIONED_TABLE)
+			ereport(ERROR,
+					(errcode(ERRCODE_WRONG_OBJECT_TYPE),
+					 errmsg("cannot wait for lockers on %s", relname),
+					 errdetail_relkind_not_supported(relkind)));
+
+		/* Require some permission */
+		aclresult = pg_class_aclcheck(reloid, GetUserId(),
+									  (ACL_SELECT | ACL_UPDATE | ACL_INSERT |
+									   ACL_DELETE | ACL_TRUNCATE));
+		if (aclresult != ACLCHECK_OK)
+			aclcheck_error(aclresult, get_relkind_objtype(relkind), relname);
+
+		heaplocktag = palloc_object(LOCKTAG);
+		if (IsSharedRelation(reloid))
+			dbid = InvalidOid;
+		else
+			dbid = MyDatabaseId;
+		SET_LOCKTAG_RELATION(*heaplocktag, dbid, reloid);
+
+		locktags = lappend(locktags, heaplocktag);
+	}
+	WaitForLockersMultiple(locktags, mode, conflicting, false);
+
+	PG_RETURN_VOID();
+}
+
+
 /*
  * pg_isolation_test_session_is_blocked - support function for isolationtester
  *
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 7979392776..d36bf301d9 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -6304,6 +6304,11 @@
   proname => 'pg_safe_snapshot_blocking_pids', provolatile => 'v',
   prorettype => '_int4', proargtypes => 'int4',
   prosrc => 'pg_safe_snapshot_blocking_pids' },
+{ oid => '7178',
+  descr => 'wait for already-held table locks to be released',
+  proname => 'pg_wait_for_lockers', provolatile => 'v',
+  prorettype => 'void', proargtypes => '_regclass text bool',
+  prosrc => 'pg_wait_for_lockers' },
 { oid => '3378', descr => 'isolationtester support function',
   proname => 'pg_isolation_test_session_is_blocked', provolatile => 'v',
   prorettype => 'bool', proargtypes => 'int4 _int4',
diff --git a/src/include/storage/lock.h b/src/include/storage/lock.h
index 62c50597a8..44101e9d3e 100644
--- a/src/include/storage/lock.h
+++ b/src/include/storage/lock.h
@@ -593,6 +593,8 @@ extern BlockedProcsData *GetBlockerStatusData(int blocked_pid);
 
 extern xl_standby_lock *GetRunningTransactionLocks(int *nlocks);
 extern const char *GetLockmodeName(LOCKMETHODID lockmethodid, LOCKMODE mode);
+extern LOCKMODE ParseLockmodeName(LOCKMETHODID lockmethodid,
+								  const char *mode_name);
 
 extern void lock_twophase_recover(TransactionId xid, uint16 info,
 								  void *recdata, uint32 len);
diff --git a/src/test/isolation/expected/deadlock-wait-for-lockers.out b/src/test/isolation/expected/deadlock-wait-for-lockers.out
new file mode 100644
index 0000000000..8447ff6f05
--- /dev/null
+++ b/src/test/isolation/expected/deadlock-wait-for-lockers.out
@@ -0,0 +1,19 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1lre s2las s2wfl s1wfl s1c s2c
+step s1lre: LOCK TABLE a1 IN ROW EXCLUSIVE MODE;
+step s2las: LOCK TABLE a1 IN ACCESS SHARE MODE;
+step s2wfl: SELECT pg_wait_for_lockers(array['a1']::regclass[],
+										 'ShareLock', TRUE); <waiting ...>
+step s1wfl: SELECT pg_wait_for_lockers(array['a1']::regclass[],
+										 'AccessExclusiveLock', TRUE); <waiting ...>
+step s1wfl: <... completed>
+pg_wait_for_lockers
+-------------------
+                   
+(1 row)
+
+step s2wfl: <... completed>
+ERROR:  deadlock detected
+step s1c: COMMIT;
+step s2c: COMMIT;
diff --git a/src/test/isolation/expected/wait-for-lockers.out b/src/test/isolation/expected/wait-for-lockers.out
new file mode 100644
index 0000000000..a3e892549d
--- /dev/null
+++ b/src/test/isolation/expected/wait-for-lockers.out
@@ -0,0 +1,180 @@
+Parsed test spec with 3 sessions
+
+starting permutation: w1_lae2 w2_in1 w2_c w1_c r_wflc r_sel1 r_c
+step w1_lae2: LOCK TABLE t2 IN ACCESS EXCLUSIVE MODE;
+step w2_in1: INSERT INTO t1 VALUES (DEFAULT);
+step w2_c: COMMIT;
+step w1_c: COMMIT;
+step r_wflc: SELECT pg_wait_for_lockers(array['t1','t2']::regclass[],
+											 'ShareLock', TRUE);
+pg_wait_for_lockers
+-------------------
+                   
+(1 row)
+
+step r_sel1: SELECT id from t1;
+id
+--
+ 1
+(1 row)
+
+step r_c: COMMIT;
+
+starting permutation: w1_lae2 w2_in1 r_wfl w2_c r_sel1 w1_c r_c
+step w1_lae2: LOCK TABLE t2 IN ACCESS EXCLUSIVE MODE;
+step w2_in1: INSERT INTO t1 VALUES (DEFAULT);
+step r_wfl: SELECT pg_wait_for_lockers(array['t1','t2']::regclass[],
+											 'RowExclusiveLock', FALSE); <waiting ...>
+step w2_c: COMMIT;
+step r_wfl: <... completed>
+pg_wait_for_lockers
+-------------------
+                   
+(1 row)
+
+step r_sel1: SELECT id from t1;
+id
+--
+ 1
+(1 row)
+
+step w1_c: COMMIT;
+step r_c: COMMIT;
+
+starting permutation: w1_lae2 w2_in1 r_wflc w2_c w1_c r_sel1 r_c
+step w1_lae2: LOCK TABLE t2 IN ACCESS EXCLUSIVE MODE;
+step w2_in1: INSERT INTO t1 VALUES (DEFAULT);
+step r_wflc: SELECT pg_wait_for_lockers(array['t1','t2']::regclass[],
+											 'ShareLock', TRUE); <waiting ...>
+step w2_c: COMMIT;
+step w1_c: COMMIT;
+step r_wflc: <... completed>
+pg_wait_for_lockers
+-------------------
+                   
+(1 row)
+
+step r_sel1: SELECT id from t1;
+id
+--
+ 1
+(1 row)
+
+step r_c: COMMIT;
+
+starting permutation: w1_in1 r_wflc w2_in1 w2_c w1_c r_sel1 r_c
+step w1_in1: INSERT INTO t1 VALUES (DEFAULT);
+step r_wflc: SELECT pg_wait_for_lockers(array['t1','t2']::regclass[],
+											 'ShareLock', TRUE); <waiting ...>
+step w2_in1: INSERT INTO t1 VALUES (DEFAULT);
+step w2_c: COMMIT;
+step w1_c: COMMIT;
+step r_wflc: <... completed>
+pg_wait_for_lockers
+-------------------
+                   
+(1 row)
+
+step r_sel1: SELECT id from t1;
+id
+--
+ 1
+ 2
+(2 rows)
+
+step r_c: COMMIT;
+
+starting permutation: w1_in1 r_sv r_l w2_in1 w1_c r_rb w2_c r_sel1 r_c
+step w1_in1: INSERT INTO t1 VALUES (DEFAULT);
+step r_sv: SAVEPOINT foo;
+step r_l: LOCK TABLE t1, t2 IN SHARE MODE; <waiting ...>
+step w2_in1: INSERT INTO t1 VALUES (DEFAULT); <waiting ...>
+step w1_c: COMMIT;
+step r_l: <... completed>
+step r_rb: ROLLBACK TO foo;
+step w2_in1: <... completed>
+step w2_c: COMMIT;
+step r_sel1: SELECT id from t1;
+id
+--
+ 1
+ 2
+(2 rows)
+
+step r_c: COMMIT;
+
+starting permutation: w2_in1 r_wflc w1_lae2 w1_in1 w2_c r_sel1 w1_c r_c
+step w2_in1: INSERT INTO t1 VALUES (DEFAULT);
+step r_wflc: SELECT pg_wait_for_lockers(array['t1','t2']::regclass[],
+											 'ShareLock', TRUE); <waiting ...>
+step w1_lae2: LOCK TABLE t2 IN ACCESS EXCLUSIVE MODE;
+step w1_in1: INSERT INTO t1 VALUES (DEFAULT);
+step w2_c: COMMIT;
+step r_wflc: <... completed>
+pg_wait_for_lockers
+-------------------
+                   
+(1 row)
+
+step r_sel1: SELECT id from t1;
+id
+--
+ 1
+(1 row)
+
+step w1_c: COMMIT;
+step r_c: COMMIT;
+
+starting permutation: w2_in1 r_sv r_l w1_lae2 w2_c w1_c r_rb r_sel1 r_c
+step w2_in1: INSERT INTO t1 VALUES (DEFAULT);
+step r_sv: SAVEPOINT foo;
+step r_l: LOCK TABLE t1, t2 IN SHARE MODE; <waiting ...>
+step w1_lae2: LOCK TABLE t2 IN ACCESS EXCLUSIVE MODE;
+step w2_c: COMMIT;
+step w1_c: COMMIT;
+step r_l: <... completed>
+step r_rb: ROLLBACK TO foo;
+step r_sel1: SELECT id from t1;
+id
+--
+ 1
+(1 row)
+
+step r_c: COMMIT;
+
+starting permutation: w1_lae1 w2_in1 r_wflc w1_c r_sel1 w2_c r_c
+step w1_lae1: LOCK TABLE t1 IN ACCESS EXCLUSIVE MODE;
+step w2_in1: INSERT INTO t1 VALUES (DEFAULT); <waiting ...>
+step r_wflc: SELECT pg_wait_for_lockers(array['t1','t2']::regclass[],
+											 'ShareLock', TRUE); <waiting ...>
+step w1_c: COMMIT;
+step w2_in1: <... completed>
+step r_wflc: <... completed>
+pg_wait_for_lockers
+-------------------
+                   
+(1 row)
+
+step r_sel1: SELECT id from t1;
+id
+--
+(0 rows)
+
+step w2_c: COMMIT;
+step r_c: COMMIT;
+
+starting permutation: w1_lae1 w2_in1 r_l w1_c w2_c r_sel1 r_c
+step w1_lae1: LOCK TABLE t1 IN ACCESS EXCLUSIVE MODE;
+step w2_in1: INSERT INTO t1 VALUES (DEFAULT); <waiting ...>
+step r_l: LOCK TABLE t1, t2 IN SHARE MODE; <waiting ...>
+step w1_c: COMMIT;
+step w2_in1: <... completed>
+step w2_c: COMMIT;
+step r_l: <... completed>
+step r_sel1: SELECT id from t1;
+id
+--
+ 1
+(1 row)
+
+step r_c: COMMIT;
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b2be88ead1..b7380627d7 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -5,6 +5,7 @@ test: read-write-unique
 test: read-write-unique-2
 test: read-write-unique-3
 test: read-write-unique-4
+test: wait-for-lockers
 test: simple-write-skew
 test: receipt-report
 test: temporal-range-integrity
@@ -20,6 +21,7 @@ test: index-only-scan
 test: predicate-lock-hot-tuple
 test: update-conflict-out
 test: deadlock-simple
+test: deadlock-wait-for-lockers
 test: deadlock-hard
 test: deadlock-soft
 test: deadlock-soft-2
diff --git a/src/test/isolation/specs/deadlock-wait-for-lockers.spec b/src/test/isolation/specs/deadlock-wait-for-lockers.spec
new file mode 100644
index 0000000000..47f07004ae
--- /dev/null
+++ b/src/test/isolation/specs/deadlock-wait-for-lockers.spec
@@ -0,0 +1,25 @@
+setup
+{
+	CREATE TABLE a1 ();
+}
+
+teardown
+{
+	DROP TABLE a1;
+}
+
+session s1
+setup		{ BEGIN; }
+step s1lre	{ LOCK TABLE a1 IN ROW EXCLUSIVE MODE; }
+step s1wfl	{ SELECT pg_wait_for_lockers(array['a1']::regclass[],
+										 'AccessExclusiveLock', TRUE); }
+step s1c	{ COMMIT; }
+
+session s2
+setup		{ BEGIN; }
+step s2las	{ LOCK TABLE a1 IN ACCESS SHARE MODE; }
+step s2wfl	{ SELECT pg_wait_for_lockers(array['a1']::regclass[],
+										 'ShareLock', TRUE); }
+step s2c	{ COMMIT; }
+
+permutation s1lre s2las s2wfl s1wfl s1c s2c
diff --git a/src/test/isolation/specs/wait-for-lockers.spec b/src/test/isolation/specs/wait-for-lockers.spec
new file mode 100644
index 0000000000..84f203bd57
--- /dev/null
+++ b/src/test/isolation/specs/wait-for-lockers.spec
@@ -0,0 +1,78 @@
+setup
+{
+	CREATE TABLE t1 (id bigserial);
+	CREATE TABLE t2 (id bigserial);
+}
+
+teardown
+{
+	DROP TABLE t1;
+	DROP TABLE t2;
+}
+
+# use READ COMMITTED so we can observe the effects of a committed INSERT after
+# waiting
+
+session writer1
+setup			{ BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED; }
+step w1_in1		{ INSERT INTO t1 VALUES (DEFAULT); }
+step w1_lae1	{ LOCK TABLE t1 IN ACCESS EXCLUSIVE MODE; }
+step w1_lae2	{ LOCK TABLE t2 IN ACCESS EXCLUSIVE MODE; }
+step w1_c	{ COMMIT; }
+
+session writer2
+setup		{ BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED; }
+step w2_in1	{ INSERT INTO t1 VALUES (DEFAULT); }
+step w2_c	{ COMMIT; }
+
+session reader
+setup			{ BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED; }
+step r_sv		{ SAVEPOINT foo; }
+step r_l		{ LOCK TABLE t1, t2 IN SHARE MODE; }
+step r_rb		{ ROLLBACK TO foo; }
+step r_wfl		{ SELECT pg_wait_for_lockers(array['t1','t2']::regclass[],
+											 'RowExclusiveLock', FALSE); }
+step r_wflc		{ SELECT pg_wait_for_lockers(array['t1','t2']::regclass[],
+											 'ShareLock', TRUE); }
+step r_sel1		{ SELECT id from t1; }
+step r_c		{ COMMIT; }
+
+
+# Basic sanity checks of WAIT FOR LOCKERS:
+
+# no waiting if no lockers (writers already committed)
+permutation w1_lae2 w2_in1 w2_c w1_c r_wflc r_sel1 r_c
+
+# reader waits only for writer2 holding a lock in ROW EXCLUSIVE mode, not for
+# writer1 holding a lock in ACCESS EXCLUSIVE mode
+permutation w1_lae2 w2_in1 r_wfl w2_c r_sel1 w1_c r_c
+
+# reader waits for both writers conflcting with SHARE mode
+permutation w1_lae2 w2_in1 r_wflc w2_c w1_c r_sel1 r_c
+
+
+# Comparisons between WAIT FOR LOCKERS and nearest equivalent LOCK + ROLLBACK:
+
+# reader waiting for writer1 allows writer2 to take a matching lock...
+permutation w1_in1 r_wflc w2_in1 w2_c w1_c r_sel1 r_c
+# ...whereas reader actually taking a conflcting lock blocks writer2 until
+# writer1 releases its lock (even if reader releases ASAP)
+permutation w1_in1 r_sv r_l w2_in1 w1_c r_rb w2_c r_sel1 r_c
+
+# reader waiting for two tables, with only writer2 holding a matching ROW
+# EXCLUSIVE lock, allows writer1 to then take an ACCESS EXCLUSIVE lock on t2 and
+# another ROW EXCLUSIVE lock on t1, and reader doesn't wait for writer1's later
+# locks...
+permutation w2_in1 r_wflc w1_lae2 w1_in1 w2_c r_sel1 w1_c r_c
+# ...whereas reader actually taking conflcting locks on the two tables first
+# waits for writer2's ROW EXCLUSIVE lock (same as above), and then for writer1's
+# *later* ACCESS EXCLUSIVE lock (due to LOCK's one-by-one locking); note that
+# writer1's later insert w1_in1 would deadlock so it's omitted altogether
+permutation w2_in1 r_sv r_l w1_lae2 w2_c w1_c r_rb r_sel1 r_c
+
+# reader waits only for matching lock already held by writer1, not for writer2
+# which was waiting to take a matching lock...
+permutation w1_lae1 w2_in1 r_wflc w1_c r_sel1 w2_c r_c
+# ...whereas actually taking a conflcting lock also waits for writer2 to
+# release its lock
+permutation w1_lae1 w2_in1 r_l w1_c w2_c r_sel1 r_c
diff --git a/src/test/regress/expected/wait_for_lockers.out b/src/test/regress/expected/wait_for_lockers.out
new file mode 100644
index 0000000000..d7dc9c5ac8
--- /dev/null
+++ b/src/test/regress/expected/wait_for_lockers.out
@@ -0,0 +1,185 @@
+--
+-- Test the pg_wait_for_lockers() function
+--
+-- directory paths and dlsuffix are passed to us in environment variables
+\getenv libdir PG_LIBDIR
+\getenv dlsuffix PG_DLSUFFIX
+\set regresslib :libdir '/regress' :dlsuffix
+-- Setup
+CREATE SCHEMA wfl_schema1;
+SET search_path = wfl_schema1;
+CREATE TABLE wfl_tbl1 (a BIGINT);
+CREATE ROLE regress_rol_wfl1;
+ALTER ROLE regress_rol_wfl1 SET search_path = wfl_schema1;
+GRANT USAGE ON SCHEMA wfl_schema1 TO regress_rol_wfl1;
+-- Try all valid options; also try omitting the optional TABLE keyword.
+BEGIN TRANSACTION;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessShareLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'RowShareLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'RowExclusiveLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareUpdateExclusiveLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareRowExclusiveLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ExclusiveLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessShareLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'RowShareLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'RowExclusiveLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareUpdateExclusiveLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareRowExclusiveLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ExclusiveLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+ROLLBACK;
+-- pg_wait_for_lockers() does nothing if the transaction itself is the only locker
+BEGIN TRANSACTION;
+LOCK TABLE wfl_tbl1 IN ACCESS EXCLUSIVE MODE;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+ROLLBACK;
+-- pg_wait_for_lockers() is allowed outside a transaction
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+-- pg_wait_for_lockers() requires some permissions regardless of lock mode
+-- fail without permissions
+SET ROLE regress_rol_wfl1;
+BEGIN;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessShareLock', FALSE);
+ERROR:  permission denied for table wfl_tbl1
+ROLLBACK;
+BEGIN;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessShareLock', TRUE);
+ERROR:  permission denied for table wfl_tbl1
+ROLLBACK;
+RESET ROLE;
+-- succeed with only SELECT permissions and ACCESS EXCLUSIVE mode
+GRANT SELECT ON TABLE wfl_tbl1 TO regress_rol_wfl1;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', FALSE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', TRUE);
+ pg_wait_for_lockers 
+---------------------
+ 
+(1 row)
+
+RESET ROLE;
+REVOKE SELECT ON TABLE wfl_tbl1 FROM regress_rol_wfl1;
+-- fail gracefully with bogus arguments
+BEGIN;
+select pg_wait_for_lockers('{0}'::oid[]::regclass[], 'AccessShareLock', FALSE);
+ERROR:  relation oid 0 is invalid
+ROLLBACK;
+BEGIN;
+select pg_wait_for_lockers('{987654321}'::oid[]::regclass[], 'AccessShareLock', FALSE);
+ERROR:  relation oid 987654321 does not exist
+ROLLBACK;
+BEGIN;
+-- views are not supported
+select pg_wait_for_lockers('{pg_locks}'::regclass[], 'AccessShareLock', FALSE);
+ERROR:  cannot wait for lockers on pg_locks
+DETAIL:  This operation is not supported for views.
+ROLLBACK;
+--
+-- Clean up
+--
+DROP TABLE wfl_tbl1;
+DROP SCHEMA wfl_schema1 CASCADE;
+DROP ROLE regress_rol_wfl1;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index f0987ff537..d2ec0a6a86 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -48,7 +48,7 @@ test: create_index create_index_spgist create_view index_including index_includi
 # ----------
 # Another group of parallel tests
 # ----------
-test: create_aggregate create_function_sql create_cast constraints triggers select inherit typed_table vacuum drop_if_exists updatable_views roleattributes create_am hash_func errors infinite_recurse
+test: create_aggregate create_function_sql create_cast constraints triggers select inherit typed_table vacuum drop_if_exists updatable_views roleattributes create_am hash_func errors infinite_recurse wait_for_lockers
 
 # ----------
 # sanity_check does a vacuum, affecting the sort order of SELECT *
diff --git a/src/test/regress/sql/wait_for_lockers.sql b/src/test/regress/sql/wait_for_lockers.sql
new file mode 100644
index 0000000000..c3b2ff1395
--- /dev/null
+++ b/src/test/regress/sql/wait_for_lockers.sql
@@ -0,0 +1,84 @@
+--
+-- Test the pg_wait_for_lockers() function
+--
+
+-- directory paths and dlsuffix are passed to us in environment variables
+\getenv libdir PG_LIBDIR
+\getenv dlsuffix PG_DLSUFFIX
+
+\set regresslib :libdir '/regress' :dlsuffix
+
+-- Setup
+CREATE SCHEMA wfl_schema1;
+SET search_path = wfl_schema1;
+CREATE TABLE wfl_tbl1 (a BIGINT);
+CREATE ROLE regress_rol_wfl1;
+ALTER ROLE regress_rol_wfl1 SET search_path = wfl_schema1;
+GRANT USAGE ON SCHEMA wfl_schema1 TO regress_rol_wfl1;
+
+-- Try all valid options; also try omitting the optional TABLE keyword.
+BEGIN TRANSACTION;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessShareLock', FALSE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'RowShareLock', FALSE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'RowExclusiveLock', FALSE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareUpdateExclusiveLock', FALSE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareLock', FALSE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareRowExclusiveLock', FALSE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ExclusiveLock', FALSE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', FALSE);
+
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessShareLock', TRUE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'RowShareLock', TRUE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'RowExclusiveLock', TRUE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareUpdateExclusiveLock', TRUE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareLock', TRUE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ShareRowExclusiveLock', TRUE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'ExclusiveLock', TRUE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', TRUE);
+ROLLBACK;
+
+-- pg_wait_for_lockers() does nothing if the transaction itself is the only locker
+BEGIN TRANSACTION;
+LOCK TABLE wfl_tbl1 IN ACCESS EXCLUSIVE MODE;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', TRUE);
+ROLLBACK;
+
+-- pg_wait_for_lockers() is allowed outside a transaction
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', FALSE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', TRUE);
+
+-- pg_wait_for_lockers() requires some permissions regardless of lock mode
+-- fail without permissions
+SET ROLE regress_rol_wfl1;
+BEGIN;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessShareLock', FALSE);
+ROLLBACK;
+BEGIN;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessShareLock', TRUE);
+ROLLBACK;
+RESET ROLE;
+-- succeed with only SELECT permissions and ACCESS EXCLUSIVE mode
+GRANT SELECT ON TABLE wfl_tbl1 TO regress_rol_wfl1;
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', FALSE);
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessExclusiveLock', TRUE);
+RESET ROLE;
+REVOKE SELECT ON TABLE wfl_tbl1 FROM regress_rol_wfl1;
+
+-- fail gracefully with bogus arguments
+BEGIN;
+select pg_wait_for_lockers('{0}'::oid[]::regclass[], 'AccessShareLock', FALSE);
+ROLLBACK;
+BEGIN;
+select pg_wait_for_lockers('{987654321}'::oid[]::regclass[], 'AccessShareLock', FALSE);
+ROLLBACK;
+BEGIN;
+-- views are not supported
+select pg_wait_for_lockers('{pg_locks}'::regclass[], 'AccessShareLock', FALSE);
+ROLLBACK;
+
+--
+-- Clean up
+--
+DROP TABLE wfl_tbl1;
+DROP SCHEMA wfl_schema1 CASCADE;
+DROP ROLE regress_rol_wfl1;
-- 
2.34.1

