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

Rather than actually taking any locks on the table(s), it simply waits
for already-held locks to be released, 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                        |  82 ++++++++
 src/backend/storage/lmgr/lock.c               |  23 +++
 src/backend/utils/adt/lockfuncs.c             |  93 +++++++++
 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     |  79 +++++++
 .../regress/expected/wait_for_lockers.out     | 192 ++++++++++++++++++
 src/test/regress/parallel_schedule            |   2 +-
 src/test/regress/sql/wait_for_lockers.sql     |  90 ++++++++
 13 files changed, 793 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 6788ba8ef4..8e90d0e634 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -29380,6 +29380,88 @@ 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>
+        <parameter>lockmode</parameter> must be a string that specifies a
+        table-level lock mode, such as <literal>'ShareLock'</literal>. 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>
+        The function first builds a 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 calling the function,
+        even if it holds a matching lock, to avoid a trivial deadlock.
+       </para>
+       <para>
+        The function waits for table-level locks on only the specified tables.
+        To wait for locks on ancestor or descendant tables (if any), you must
+        specify them explicitly.
+       </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 0542c83f1f..dc8c4b7a2b 100644
--- a/src/backend/storage/lmgr/lock.c
+++ b/src/backend/storage/lmgr/lock.c
@@ -4038,6 +4038,29 @@ 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)));
+	/* unreachable but appease compiler */
+	return NoLock;
+}
+
 #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..6c08c7ef8b 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,95 @@ 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
+ * descendant tables (if any).
+ *
+ * Since no table-level locks are taken, there are some inherent races. The
+ * specified tables must exist long enough 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;
+
+		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)));
+
+		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 29af4ce65d..1347c84d49 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 bf8ca45ff8..f3c77e42f0 100644
--- a/src/include/storage/lock.h
+++ b/src/include/storage/lock.h
@@ -591,6 +591,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..c02bd884df
--- /dev/null
+++ b/src/test/isolation/specs/wait-for-lockers.spec
@@ -0,0 +1,79 @@
+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 pg_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 conflicting with SHARE mode
+permutation w1_lae2 w2_in1 r_wflc w2_c w1_c r_sel1 r_c
+
+
+# Comparisons between pg_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 conflicting 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 on t1, 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 conflicting 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 conflicting lock also waits for writer2 to take
+# and 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..24415edd7a
--- /dev/null
+++ b/src/test/regress/expected/wait_for_lockers.out
@@ -0,0 +1,192 @@
+--
+-- 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
+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;
+-- invalid oid
+select pg_wait_for_lockers('{0}'::oid[]::regclass[], 'AccessShareLock', FALSE);
+ERROR:  relation oid 0 is invalid
+ROLLBACK;
+BEGIN;
+-- nonexistent oid
+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;
+BEGIN;
+-- bogus lock mode
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessRowShareUpdateExclusiveLock', TRUE);
+ERROR:  invalid lock mode name AccessRowShareUpdateExclusiveLock
+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 1d8a414eea..43e4493205 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..a299cd7863
--- /dev/null
+++ b/src/test/regress/sql/wait_for_lockers.sql
@@ -0,0 +1,90 @@
+--
+-- 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
+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;
+-- invalid oid
+select pg_wait_for_lockers('{0}'::oid[]::regclass[], 'AccessShareLock', FALSE);
+ROLLBACK;
+BEGIN;
+-- nonexistent oid
+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;
+BEGIN;
+-- bogus lock mode
+select pg_wait_for_lockers('{wfl_tbl1}'::regclass[], 'AccessRowShareUpdateExclusiveLock', TRUE);
+ROLLBACK;
+
+--
+-- Clean up
+--
+DROP TABLE wfl_tbl1;
+DROP SCHEMA wfl_schema1 CASCADE;
+DROP ROLE regress_rol_wfl1;
-- 
2.34.1

